[
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "content": "---\nname: Bug report\nabout: Create a report to help us improve\ntitle: ''\nlabels: ''\nassignees: ''\n\n---\n\n<!-- Your issue may already be reported! Please search for it before creating one. -->\n\n**Describe the bug**\n<!-- A clear and concise description of what the bug is. -->\n\n**To Reproduce**\n<!-- Steps to reproduce the behavior, including any relevant code snippets. -->\n\n**Expected behavior**\n<!-- A clear and concise description of what you expected to happen. -->\n\n**Logs**\n<!-- If applicable, Attach relevant log outputs that can help diagnose the issue, see https://github.com/sigoden/aichat/wiki/FAQ#how-to-log-or-debug for logging. -->\n\n**Screenshots**\n<!-- If applicable, add screenshots to help explain your problem. -->\n\n**Configuration**\n<!-- Please run `aichat --info` and paste the output -->\n\n**Environment (please complete the following information):**\n- os version: [e.g. Ubuntu 20.04]\n- aichat version: [e.g. 0.9.0]\n- terminal version: [e.g. GNOME Terminal 3.44.0]\n\n**Additional context**\n<!-- Add any other context about the problem here. -->"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.md",
    "content": "---\nname: Feature request\nabout: Suggest an idea for this project\ntitle: ''\nlabels: enhancement\nassignees: ''\n\n---\n\n<!-- Your issue may already be reported! Please search for it before creating one. -->\n\n**Is your feature request related to a problem? Please describe.**\n<!-- A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] -->\n\n**Describe the solution you'd like**\n<!-- A clear and concise description of what you want to happen. -->\n\n**Describe alternatives you've considered**\n<!-- A clear and concise description of any alternative solutions or features you've considered. -->\n\n**Additional context**\n<!-- Add any other context or screenshots about the feature request here. -->"
  },
  {
    "path": ".github/workflows/ci.yaml",
    "content": "name: CI\n\non:\n  pull_request:\n    branches:\n    - '*'\n  push:\n    branches:\n    - main\n\ndefaults:\n  run:\n    shell: bash\n\njobs:\n  all:\n    name: All\n\n    strategy:\n      matrix:\n        os:\n        - ubuntu-latest\n        - macos-latest\n        - windows-latest\n\n    runs-on: ${{matrix.os}}\n\n    env:\n      RUSTFLAGS: --deny warnings\n\n    steps:\n    - uses: actions/checkout@v4\n\n    - name: Install Rust Toolchain Components\n      uses: dtolnay/rust-toolchain@stable\n\n    - uses: Swatinem/rust-cache@v2\n\n    - name: Test\n      run: cargo test --all\n\n    - name: Clippy\n      run: cargo clippy --all --all-targets -- -D warnings\n\n    - name: Format\n      run: cargo fmt --all --check"
  },
  {
    "path": ".github/workflows/release.yaml",
    "content": "name: Release\n\non:\n  push:\n    tags:\n    - v[0-9]+.[0-9]+.[0-9]+*\n\njobs:\n  release:\n    name: Publish to GitHub Release\n    permissions:\n      contents: write\n    outputs:\n      rc: ${{ steps.check-tag.outputs.rc }}\n\n    strategy:\n      matrix:\n        include:\n        - target: aarch64-unknown-linux-musl\n          os: ubuntu-latest\n          use-cross: true\n          cargo-flags: \"\"\n        - target: aarch64-apple-darwin\n          os: macos-latest\n          use-cross: true\n          cargo-flags: \"\"\n        - target: aarch64-pc-windows-msvc\n          os: windows-latest\n          use-cross: true\n          cargo-flags: \"\"\n        - target: x86_64-apple-darwin\n          os: macos-latest\n          cargo-flags: \"\"\n        - target: x86_64-pc-windows-msvc\n          os: windows-latest\n          cargo-flags: \"\"\n        - target: x86_64-unknown-linux-musl\n          os: ubuntu-latest\n          use-cross: true\n          cargo-flags: \"\"\n        - target: i686-unknown-linux-musl\n          os: ubuntu-latest\n          use-cross: true\n          cargo-flags: \"\"\n        - target: i686-pc-windows-msvc\n          os: windows-latest\n          use-cross: true\n          cargo-flags: \"\"\n        - target: armv7-unknown-linux-musleabihf\n          os: ubuntu-latest\n          use-cross: true\n          cargo-flags: \"\"\n        - target: arm-unknown-linux-musleabihf\n          os: ubuntu-latest\n          use-cross: true\n          cargo-flags: \"\"\n\n    runs-on: ${{matrix.os}}\n    env:\n      BUILD_CMD: cargo\n\n    steps:\n    - uses: actions/checkout@v4\n\n    - name: Check Tag\n      id: check-tag\n      shell: bash\n      run: |\n        ver=${GITHUB_REF##*/}\n        echo \"version=$ver\" >> $GITHUB_OUTPUT\n        if [[ \"$ver\" =~ [0-9]+.[0-9]+.[0-9]+$ ]]; then\n          echo \"rc=false\" >> $GITHUB_OUTPUT\n        else\n          echo \"rc=true\" >> $GITHUB_OUTPUT\n        fi\n\n\n    - name: Install Rust Toolchain Components\n      uses: dtolnay/rust-toolchain@stable\n      with:\n        targets: ${{ matrix.target }}\n\n    - name: Install cross\n      if: matrix.use-cross\n      uses: taiki-e/install-action@v2\n      with:\n        tool: cross\n\n    - name: Overwrite build command env variable\n      if: matrix.use-cross\n      shell: bash\n      run: echo \"BUILD_CMD=cross\" >> $GITHUB_ENV\n  \n    - name: Show Version Information (Rust, cargo, GCC)\n      shell: bash\n      run: |\n        gcc --version || true\n        rustup -V\n        rustup toolchain list\n        rustup default\n        cargo -V\n        rustc -V\n      \n    - name: Build\n      shell: bash\n      run: $BUILD_CMD build --locked --release --target=${{ matrix.target }} ${{ matrix.cargo-flags }}\n\n    - name: Build Archive\n      shell: bash\n      id: package\n      env:\n        target: ${{ matrix.target }}\n        version:  ${{ steps.check-tag.outputs.version }}\n      run: |\n        set -euxo pipefail\n\n        bin=${GITHUB_REPOSITORY##*/}\n        dist_dir=`pwd`/dist\n        name=$bin-$version-$target\n        executable=target/$target/release/$bin\n\n        if [[ \"$RUNNER_OS\" == \"Windows\" ]]; then\n          executable=$executable.exe\n        fi\n\n        mkdir $dist_dir\n        cp $executable $dist_dir\n        cd $dist_dir\n\n        if [[ \"$RUNNER_OS\" == \"Windows\" ]]; then\n            archive=$dist_dir/$name.zip\n            7z a $archive *\n            echo \"archive=dist/$name.zip\" >> $GITHUB_OUTPUT\n        else\n            archive=$dist_dir/$name.tar.gz\n            tar -czf $archive *\n            echo \"archive=dist/$name.tar.gz\" >> $GITHUB_OUTPUT\n        fi\n\n    - name: Publish Archive\n      uses: softprops/action-gh-release@v2\n      if: ${{ startsWith(github.ref, 'refs/tags/') }}\n      with:\n        draft: false\n        files: ${{ steps.package.outputs.archive }}\n        prerelease: ${{ steps.check-tag.outputs.rc == 'true' }}\n\n  publish-crate:\n    name: Publish to crates.io\n    if: ${{ needs.release.outputs.rc == 'false' }}\n    runs-on: ubuntu-latest\n    needs: release\n    steps:\n      - uses: actions/checkout@v4\n\n      - uses: dtolnay/rust-toolchain@stable\n\n      - name: Publish\n        env:\n          CARGO_REGISTRY_TOKEN: ${{ secrets.CRATES_IO_API_TOKEN }}\n        run: cargo publish"
  },
  {
    "path": ".gitignore",
    "content": "/target\n/tmp\n/.env\n*.log"
  },
  {
    "path": "Argcfile.sh",
    "content": "#!/usr/bin/env bash\nset -e\n\n# @meta dotenv\n# @env DRY_RUN Dry run mode\n\n# @cmd Test configuration initialization\n# @env AICHAT_CONFIG_DIR=tmp/test-init-config\n# @arg args~\ntest-init-config() {\n    unset OPENAI_API_KEY\n    mkdir -p \"$AICHAT_CONFIG_DIR\"\n    config_file=\"$AICHAT_CONFIG_DIR/config.yaml\"\n    if [[ -f \"$config_file\" ]]; then\n        rm -f \"$config_file\"\n    fi\n    cargo run -- \"$@\"\n}\n\n# @cmd Test running without configuration file\n# @env AICHAT_PROVIDER!\n# @env AICHAT_CONFIG_DIR=tmp/test-provider-env\n# @arg args~\ntest-no-config() {\n    mkdir -p \"$AICHAT_CONFIG_DIR\"\n    rm -rf \"$AICHAT_CONFIG_DIR/config.yaml\"\n    cargo run -- \"$@\"\n}\n\n# @cmd Test function calling\n# @option -m --model[?`_choice_model`]\n# @option -p --preset[=weather|multi-weathers]\n# @flag -S --no-stream\n# @arg text~\ntest-function-calling() {\n    args=(--role %functions%)\n    if [[ -n \"$argc_model\"  ]]; then\n      args+=(\"--model\" \"$argc_model\")\n    fi\n    if [[ -n \"$argc_no_stream\" ]]; then\n        args+=(\"-S\")\n    fi\n    if [[ -z \"$argc_text\" ]]; then\n        case \"$argc_preset\" in\n        multi-weathers)\n            text=\"what is the weather in London and Pairs?\"\n            ;;\n        weather|*)\n            text=\"what is the weather in London?\"\n            ;;\n        esac\n    else\n        text=\"${argc_text[*]}\"\n    fi\n    cargo run -- \"${args[@]}\" \"$text\"\n}\n\n# @cmd Test clients\n# @arg clients+[`_choice_client`]\ntest-clients() {\n    for c in \"${argc_clients[@]}\"; do\n        echo \"### $c stream\"\n        aichat -m \"$c\" 1 + 2 = ?\n        echo \"### $c non-stream\"\n        aichat -m \"$c\" -S 1 + 2 = ?\n    done\n}\n\n# @cmd Test proxy server\n# @option -m --model[?`_choice_model`]\n# @flag -S --no-stream\n# @arg text~\ntest-server() {\n    args=()\n    if [[ -n \"$argc_no_stream\" ]]; then\n        args+=(\"-S\")\n    fi\n    argc chat-llm \"${args[@]}\" \\\n    --api-base http://localhost:8000/v1 \\\n    --model \"${argc_model:-default}\" \\\n    \"$@\"\n}\n\n# @cmd Chat with any LLM api \n# @flag -S --no-stream\n# @arg provider_model![?`_choice_provider_model`]\n# @arg text~\nchat() {\n    if [[ \"$argc_provider_model\" == *':'* ]]; then\n        model=\"${argc_provider_model##*:}\"\n        argc_provider=\"${argc_provider_model%:*}\"\n    else\n        argc_provider=\"${argc_provider_model}\"\n    fi\n    for provider_config in \"${OPENAI_COMPATIBLE_PROVIDERS[@]}\"; do\n        if [[ \"$argc_provider\" == \"${provider_config%%,*}\" ]]; then\n            _retrieve_api_base\n            break\n        fi\n    done\n    if [[ -n \"$api_base\" ]]; then\n        env_prefix=\"$(echo \"$argc_provider\" | tr '[:lower:]' '[:upper:]')\"\n        api_key_env=\"${env_prefix}_API_KEY\"\n        api_key=\"${!api_key_env}\" \n        if [[ -z \"$model\" ]]; then\n            model=\"$(echo \"$provider_config\" | cut -d, -f2)\"\n        fi\n        if [[ -z \"$model\" ]]; then\n            model_env=\"${env_prefix}_MODEL\"\n            model=\"${!model_env}\"\n        fi\n        argc chat-openai-compatible \\\n            --api-base \"$api_base\" \\\n            --api-key \"$api_key\" \\\n            --model \"$model\" \\\n            \"${argc_text[@]}\"\n    else\n        argc chat-$argc_provider \"${argc_text[@]}\"\n    fi\n}\n\n# @cmd List models by openai-compatible api\n# @flag --name-only Print model name only\n# @arg provider![`_choice_provider`]\nmodels() {\n    for provider_config in \"${OPENAI_COMPATIBLE_PROVIDERS[@]}\"; do\n        if [[ \"$argc_provider\" == \"${provider_config%%,*}\" ]]; then\n            _retrieve_api_base\n            break\n        fi\n    done\n    if [[ -n \"$api_base\" ]]; then\n        env_prefix=\"$(echo \"$argc_provider\" | tr '[:lower:]' '[:upper:]')\"\n        api_key_env=\"${env_prefix}_API_KEY\"\n        api_key=\"${!api_key_env}\" \n        jq_args=()\n        if [[ -n \"$argc_name_only\" ]]; then\n            case \"$argc_provider\" in\n                cloudflare)\n                    jq_args+=(-r '.result[].name')\n                    ;;\n                github)\n                    jq_args+=(-r '.[].name')\n                    ;;\n                *)\n                    jq_args+=(-r '.data[].id')\n                    ;;\n            esac\n        fi\n        _openai_compatible_models | jq \"${jq_args[@]}\"\n    else\n        if ! cat \"$0\" | grep -q \"^models-$argc_provider\"; then\n            _die \"error: provider '$argc_provider' does not have a models api\"\n        fi\n        cli_args=()\n        if [[ -n \"$argc_name_only\" ]]; then\n            cli_args+=(--name-only)\n        fi\n        argc models-$argc_provider \"${cli_args[@]}\"\n    fi\n}\n\n# @cmd Chat with openai-compatible api\n# @option --api-base! $$ \n# @option --api-key! $$\n# @option -m --model! $$\n# @flag -S --no-stream\n# @arg text~\nchat-openai-compatible() {\n    _wrapper curl -i \"$argc_api_base/chat/completions\" \\\n-X POST \\\n-H \"Content-Type: application/json\" \\\n-H \"Authorization: Bearer $argc_api_key\" \\\n-d \"$(_build_body openai \"$@\")\"\n}\n\n# @cmd List models by openai-compatible api\n# @option --api-base! $$\n# @option --api-key! $$\n# @flag --name-only Print model name only\nmodels-openai-compatible() {\n    jq_args=()\n    if [[ -n \"$argc_name_only\" ]]; then\n        jq_args+=(-r '.data[].id')\n    fi\n    _openai_compatible_models | jq \"${jq_args[@]}\"\n}\n\n# @cmd Chat with azure-openai api\n# @option --api-url! $$ \n# @option --api-key! $$\n# @option -m --model! $$\n# @flag -S --no-stream\n# @arg text~\nchat-azure-openai() {\n    _wrapper curl -i \"$argc_api_url\" \\\n-X POST \\\n-H \"Content-Type: application/json\" \\\n-H \"api-key: $argc_api_key\" \\\n-d \"$(_build_body openai \"$@\")\"\n}\n\n# @cmd Chat with gemini api\n# @env GEMINI_API_KEY!\n# @option -m --model=gemini-1.5-pro-latest $GEMINI_MODEL\n# @flag -S --no-stream\n# @arg text~\nchat-gemini() {\n    method=\"streamGenerateContent\"\n    if [[ -n \"$argc_no_stream\" ]]; then\n        method=\"generateContent\"\n    fi\n    _wrapper curl -i \"https://generativelanguage.googleapis.com/v1beta/models/${argc_model}:${method}?key=${GEMINI_API_KEY}\" \\\n-i -X POST \\\n-H 'Content-Type: application/json' \\\n-d \"$(_build_body gemini \"$@\")\" \n}\n\n# @cmd List gemini models\n# @env GEMINI_API_KEY!\n# @flag --name-only Print model name only\nmodels-gemini() {\n    jq_args=()\n    if [[ -n \"$argc_name_only\" ]]; then\n        jq_args+=(-r '.models[].name')\n    fi\n    _wrapper curl -fsSL \"https://generativelanguage.googleapis.com/v1beta/models?key=${GEMINI_API_KEY}\" \\\n-H 'Content-Type: application/json' \\\n    | jq \"${jq_args[@]}\"\n}\n\n# @cmd Chat with claude api\n# @env CLAUDE_API_KEY!\n# @option -m --model=claude-3-haiku-20240307 $CLAUDE_MODEL\n# @flag -S --no-stream\n# @arg text~\nchat-claude() {\n    _wrapper curl -i https://api.anthropic.com/v1/messages \\\n-X POST \\\n-H 'content-type: application/json' \\\n-H 'anthropic-version: 2023-06-01' \\\n-H 'anthropic-beta: tools-2024-05-16' \\\n-H \"x-api-key: $CLAUDE_API_KEY\" \\\n-d \"$(_build_body claude \"$@\")\"\n}\n\n# @cmd List claude models\n# @env CLAUDE_API_KEY!\n# @flag --name-only Print model name only\nmodels-claude() {\n    jq_args=()\n    if [[ -n \"$argc_name_only\" ]]; then\n        jq_args+=(-r '.data[].id')\n    fi\n    _wrapper curl -fsSL \"https://api.anthropic.com/v1/models\" \\\n-H 'Content-Type: application/json' \\\n-H 'anthropic-version: 2023-06-01' \\\n-H \"x-api-key: $CLAUDE_API_KEY\" \\\n    | jq \"${jq_args[@]}\"\n}\n\n# @cmd Chat with cohere api\n# @env COHERE_API_KEY!\n# @option -m --model=command-r-08-2024 $COHERE_MODEL\n# @flag -S --no-stream\n# @arg text~\nchat-cohere() {\n    _wrapper curl -i https://api.cohere.ai/v2/chat \\\n-X POST \\\n-H 'Content-Type: application/json' \\\n-H \"Authorization: Bearer $COHERE_API_KEY\" \\\n-d \"$(_build_body cohere \"$@\")\"\n}\n\n# @cmd List cohere models\n# @env COHERE_API_KEY!\n# @flag --name-only Print model name only\nmodels-cohere() {\n    jq_args=()\n    if [[ -n \"$argc_name_only\" ]]; then\n        jq_args+=(-r '.models[].name')\n    fi\n    _wrapper curl -fsSL https://api.cohere.ai/v1/models \\\n-H \"Authorization: Bearer $COHERE_API_KEY\" \\\n    | jq \"${jq_args[@]}\"\n}\n\n# @cmd Chat with vertexai api\n# @env require-tools gcloud\n# @env VERTEXAI_PROJECT_ID!\n# @env VERTEXAI_LOCATION!\n# @option -m --model=gemini-1.5-flash-002 $VERTEXAI_GEMINI_MODEL\n# @flag -S --no-stream\n# @arg text~\nchat-vertexai() {\n    api_key=\"$(gcloud auth print-access-token)\"\n    func=\"streamGenerateContent\"\n    if [[ -n \"$argc_no_stream\" ]]; then\n        func=\"generateContent\"\n    fi\n    url=https://$VERTEXAI_LOCATION-aiplatform.googleapis.com/v1/projects/$VERTEXAI_PROJECT_ID/locations/$VERTEXAI_LOCATION/publishers/google/models/$argc_model:$func\n    _wrapper curl -i $url \\\n-X POST \\\n-H \"Authorization: Bearer $api_key\" \\\n-H 'Content-Type: application/json' \\\n-d \"$(_build_body vertexai \"$@\")\" \n}\n\n_argc_before() {\n    OPENAI_COMPATIBLE_PROVIDERS=( \\\n        openai,gpt-4o-mini,https://api.openai.com/v1 \\\n        ai21,jamba-1.5-mini,https://api.ai21.com/studio/v1 \\\n        cloudflare,@cf/meta/llama-3.1-8b-instruct,https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1 \\\n        deepinfra,meta-llama/Meta-Llama-3.1-8B-Instruct,https://api.deepinfra.com/v1/openai \\\n        deepseek,deepseek-chat,https://api.deepseek.com \\\n        ernie,ernie-4.0-turbo-8k-latest,https://qianfan.baidubce.com/v2 \\\n        github,gpt-4o-mini,https://models.inference.ai.azure.com \\\n        groq,llama-3.1-8b-instant,https://api.groq.com/openai/v1 \\\n        hunyuan,hunyuan-large,https://api.hunyuan.cloud.tencent.com/v1 \\\n        minimax,MiniMax-Text-01,https://api.minimax.chat/v1 \\\n        mistral,mistral-small-latest,https://api.mistral.ai/v1 \\\n        moonshot,moonshot-v1-8k,https://api.moonshot.cn/v1 \\\n        openrouter,openai/gpt-4o-mini,https://openrouter.ai/api/v1 \\\n        perplexity,llama-3.1-8b-instruct,https://api.perplexity.ai \\\n        qianwen,qwen-turbo-latest,https://dashscope.aliyuncs.com/compatible-mode/v1 \\\n        xai,grok-beta,https://api.x.ai/v1 \\\n        zhipuai,glm-4-0520,https://open.bigmodel.cn/api/paas/v4 \\\n    )\n\n    stream=\"true\"\n    if [[ -n \"$argc_no_stream\" ]]; then\n        stream=\"false\"\n    fi\n}\n\n_openai_compatible_models() {\n    api_base=\"${api_base:-\"$argc_api_base\"}\"\n    api_key=\"${api_key:-\"$argc_api_key\"}\"\n    url=\"${api_base}/models\"\n    if [[ \"$argc_provider\" == \"cloudflare\" ]]; then\n        url=\"https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/models/search\"\n    fi\n\n    _wrapper curl -fsSL \"$url\" \\\n-H \"Authorization: Bearer $api_key\" \\\n\n}\n\n_retrieve_api_base() {\n    api_base=\"${provider_config##*,}\"\n    if [[ -z \"$api_base\" ]]; then\n        key=\"$(echo $argc_provider |  tr '[:lower:]' '[:upper:]')_API_BASE\"\n        api_base=\"${!key}\"\n        if [[ -z \"$api_base\" ]]; then\n            _die \"error: miss api_base for $argc_provider; please set $key\"\n        fi\n    fi\n}\n\n_choice_model() {\n    aichat --list-models\n}\n\n_choice_provider_model() {\n    _choice_provider\n    _choice_model\n}\n\n_choice_provider() {\n    _choice_client\n    _choice_openai_compatible_provider\n}\n\n_choice_client() {\n    printf \"%s\\n\" gemini claude cohere azure-openai vertexai bedrock\n}\n\n_choice_openai_compatible_provider() {\n    for provider_config in \"${OPENAI_COMPATIBLE_PROVIDERS[@]}\"; do\n        echo \"${provider_config%%,*}\"\n    done\n}\n\n_build_body() {\n    kind=\"$1\"\n    if [[ \"$#\" -eq 1 ]]; then\n        file=\"${BODY_FILE:-\"tmp/body/$1.json\"}\"\n        if [[ -f \"$file\" ]]; then\n            cat \"$file\" | \\\n            sed -E \\\n                -e 's%\"model\": \".*\"%\"model\": \"'\"$argc_model\"'\"%' \\\n                -e 's%\"stream\": (true|false)%\"stream\": '$stream'%' \\\n\n        fi\n    else\n        shift\n        case \"$kind\" in\n        openai|cohere)\n            echo '{\n    \"model\": \"'$argc_model'\",\n    \"messages\": [\n        {\n            \"role\": \"user\",\n            \"content\": \"'\"$*\"'\"\n        }\n    ],\n    \"stream\": '$stream'\n}'\n            ;;\n        claude)\n            echo '{\n    \"model\": \"'$argc_model'\",\n    \"messages\": [\n        {\n            \"role\": \"user\",\n            \"content\": \"'\"$*\"'\"\n        }\n    ],\n    \"max_tokens\": 4096,\n    \"stream\": '$stream'\n}'\n\n            ;;\n        gemini|vertexai)\n            echo '{\n    \"contents\": [{\n        \"role\": \"user\",\n        \"parts\": [\n            {\n                \"text\": \"'\"$*\"'\"\n            }\n        ]\n    }],\n    \"safetySettings\":[{\"category\":\"HARM_CATEGORY_HARASSMENT\",\"threshold\":\"BLOCK_ONLY_HIGH\"},{\"category\":\"HARM_CATEGORY_HATE_SPEECH\",\"threshold\":\"BLOCK_ONLY_HIGH\"},{\"category\":\"HARM_CATEGORY_SEXUALLY_EXPLICIT\",\"threshold\":\"BLOCK_ONLY_HIGH\"},{\"category\":\"HARM_CATEGORY_DANGEROUS_CONTENT\",\"threshold\":\"BLOCK_ONLY_HIGH\"}]\n}'\n            ;;\n        *)\n            _die \"error: unsupported build body for $kind\"\n            ;;\n        esac\n\n    fi\n}\n\n_wrapper() {\n    if [[ \"$DRY_RUN\" == \"true\" ]] || [[ \"$DRY_RUN\" == \"1\" ]]; then\n        echo \"$@\" >&2\n    else\n        \"$@\"\n    fi\n}\n\n_die() {\n    echo $*\n    exit 1\n}\n\n# See more details at https://github.com/sigoden/argc\neval \"$(argc --argc-eval \"$0\" \"$@\")\"\n"
  },
  {
    "path": "Cargo.toml",
    "content": "[package]\nname = \"aichat\"\nversion = \"0.30.0\"\nedition = \"2021\"\nauthors = [\"sigoden <sigoden@gmail.com>\"]\ndescription = \"All-in-one LLM CLI Tool\"\nlicense = \"MIT OR Apache-2.0\"\nhomepage = \"https://github.com/sigoden/aichat\"\nrepository = \"https://github.com/sigoden/aichat\"\ncategories = [\"command-line-utilities\"]\nkeywords = [\"chatgpt\", \"llm\", \"cli\", \"ai\", \"repl\"]\n\n[dependencies]\nanyhow = \"1.0.69\"\nbytes = \"1.4.0\"\nclap = { version = \"4.4.8\", features = [\"derive\"] }\ndirs = \"6.0.0\"\nfutures-util = \"0.3.29\"\ninquire = \"0.7.0\"\nis-terminal = \"0.4.9\"\nreedline = \"0.40.0\"\nserde = { version = \"1.0.152\", features = [\"derive\"] }\nserde_json = { version = \"1.0.93\", features = [\"preserve_order\"] }\nserde_yaml = \"0.9.17\"\ntokio = { version = \"1.34.0\", features = [\"rt\", \"time\", \"macros\", \"signal\", \"rt-multi-thread\"] }\ntokio-graceful = \"0.2.2\"\ntokio-stream = { version = \"0.1.15\", default-features = false, features = [\"sync\"] }\ncrossterm = \"0.28.1\"\nchrono = \"0.4.23\"\nbincode = { version = \"2.0.0\", features = [\"serde\", \"std\"], default-features = false }\nparking_lot = \"0.12.1\"\nfancy-regex = \"0.14.0\"\nbase64 = \"0.22.0\"\nnu-ansi-term = \"0.50.0\"\nasync-trait = \"0.1.74\"\ntextwrap = \"0.16.0\"\nansi_colours = \"1.2.2\"\nreqwest-eventsource = \"0.6.0\"\nsimplelog = \"0.12.1\"\nlog = \"0.4.20\"\nshell-words = \"1.1.0\"\nsha2 = \"0.10.8\"\nunicode-width = \"0.2.0\"\nasync-recursion = \"1.1.1\"\nhttp = \"1.1.0\"\nhttp-body-util = \"0.1\"\nhyper = { version = \"1.0\", features = [\"full\"] }\nhyper-util = { version = \"0.1\", features = [\"server-auto\", \"client-legacy\"] }\ntime = { version = \"0.3.36\", features = [\"macros\"] }\nindexmap = { version = \"2.2.6\", features = [\"serde\"] }\nhmac = \"0.12.1\"\naws-smithy-eventstream = \"0.60.4\"\nurlencoding = \"2.1.3\"\nunicode-segmentation = \"1.11.0\"\njson-patch = { version = \"4.0.0\", default-features = false }\nbitflags = \"2.5.0\"\npath-absolutize = \"3.1.1\"\nhnsw_rs = \"0.3.0\"\nrayon = \"1.10.0\"\nuuid = { version = \"1.9.1\", features = [\"v4\"] }\nscraper = { version = \"0.23.1\", default-features = false, features = [\"deterministic\"] }\nsys-locale = \"0.3.1\"\nhtml_to_markdown = \"0.1.0\"\nrust-embed = \"8.5.0\"\nos_info = { version = \"3.8.2\", default-features = false }\nbm25 = { version = \"2.0.1\", features = [\"parallelism\"] }\nwhich = \"8.0.0\"\nfuzzy-matcher = \"0.3.7\"\nterminal-colorsaurus = \"0.4.8\"\nduct = \"1.0.0\"\n\n[dependencies.reqwest]\nversion = \"0.12.0\"\nfeatures = [\"json\", \"multipart\", \"socks\", \"rustls-tls\", \"rustls-tls-native-roots\"]\ndefault-features = false\n\n[dependencies.syntect]\nversion = \"5.0.0\"\ndefault-features = false\nfeatures = [\"parsing\", \"regex-onig\", \"plist-load\"]\n\n[target.'cfg(target_os = \"macos\")'.dependencies]\ncrossterm = { version = \"0.28.1\", features = [\"use-dev-tty\"] }\n\n[target.'cfg(target_os = \"linux\")'.dependencies]\narboard = { version = \"3.3.0\", default-features = false, features = [\"wayland-data-control\"] }\n\n[target.'cfg(not(any(target_os = \"linux\", target_os = \"android\", target_os = \"emscripten\")))'.dependencies]\narboard = { version = \"3.3.0\", default-features = false }\n\n[dev-dependencies]\npretty_assertions = \"1.4.0\"\nrand = \"0.9.0\"\n\n[profile.release]\nlto = true\nstrip = true\nopt-level = \"z\"\n"
  },
  {
    "path": "LICENSE-APACHE",
    "content": "                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"[]\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright [yyyy] [name of copyright owner]\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License.\n"
  },
  {
    "path": "LICENSE-MIT",
    "content": "The MIT License (MIT)\n\nCopyright (c) sigoden\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# AIChat: All-in-one LLM CLI Tool\n\n[![CI](https://github.com/sigoden/aichat/actions/workflows/ci.yaml/badge.svg)](https://github.com/sigoden/aichat/actions/workflows/ci.yaml)\n[![Crates](https://img.shields.io/crates/v/aichat.svg)](https://crates.io/crates/aichat)\n[![Discord](https://img.shields.io/discord/1226737085453701222?label=Discord)](https://discord.gg/mr3ZZUB9hG)\n\nAIChat is an all-in-one LLM CLI tool featuring Shell Assistant, CMD & REPL Mode, RAG, AI Tools & Agents, and More. \n\n## Install\n\n### Package Managers\n\n- **Rust Developers:** `cargo install aichat`\n- **Homebrew/Linuxbrew Users:** `brew install aichat`\n- **Pacman Users**: `pacman -S aichat`\n- **Windows Scoop Users:** `scoop install aichat`\n- **Android Termux Users:** `pkg install aichat`\n\n### Pre-built Binaries\n\nDownload pre-built binaries for macOS, Linux, and Windows from [GitHub Releases](https://github.com/sigoden/aichat/releases), extract them, and add the `aichat` binary to your `$PATH`.\n\n## Features\n\n### Multi-Providers\n\nIntegrate seamlessly with over 20 leading LLM providers through a unified interface. Supported providers include OpenAI, Claude, Gemini (Google AI Studio), Ollama, Groq, Azure-OpenAI, VertexAI, Bedrock, Github Models, Mistral, Deepseek, AI21, XAI Grok, Cohere, Perplexity, Cloudflare, OpenRouter, Ernie, Qianwen, Moonshot, ZhipuAI, MiniMax, Deepinfra, VoyageAI, any OpenAI-Compatible API provider.\n\n### CMD Mode\n\nExplore powerful command-line functionalities with AIChat's CMD mode.\n\n![aichat-cmd](https://github.com/user-attachments/assets/6c58c549-1564-43cf-b772-e1c9fe91d19c)\n\n### REPL Mode\n\nExperience an interactive Chat-REPL with features like tab autocompletion, multi-line input support, history search, configurable keybindings, and custom REPL prompts.\n\n![aichat-repl](https://github.com/user-attachments/assets/218fab08-cdae-4c3b-bcf8-39b6651f1362)\n\n### Shell Assistant\n\nElevate your command-line efficiency. Describe your tasks in natural language, and let AIChat transform them into precise shell commands. AIChat intelligently adjusts to your OS and shell environment.\n\n![aichat-execute](https://github.com/user-attachments/assets/0c77e901-0da2-4151-aefc-a2af96bbb004)\n\n### Multi-Form Input\n\nAccept diverse input forms such as stdin, local files and directories, and remote URLs, allowing flexibility in data handling.\n\n| Input             | CMD                                  | REPL                             |\n| ----------------- | ------------------------------------ | -------------------------------- |\n| CMD               | `aichat hello`                       |                                  |\n| STDIN             | `cat data.txt \\| aichat`             |                                  |\n| Last Reply        |                                      | `.file %%`                       |\n| Local files       | `aichat -f image.png -f data.txt`    | `.file image.png data.txt`       |\n| Local directories | `aichat -f dir/`                     | `.file dir/`                     |\n| Remote URLs       | `aichat -f https://example.com`      | `.file https://example.com`      |\n| External commands | ```aichat -f '`git diff`'```         | ```.file `git diff` ```          |\n| Combine Inputs    | `aichat -f dir/ -f data.txt explain` | `.file dir/ data.txt -- explain` |\n\n### Role\n\nCustomize roles to tailor LLM behavior, enhancing interaction efficiency and boosting productivity.\n\n![aichat-role](https://github.com/user-attachments/assets/023df6d2-409c-40bd-ac93-4174fd72f030)\n\n> The role consists of a prompt and model configuration.\n\n### Session\n\nMaintain context-aware conversations through sessions, ensuring continuity in interactions.\n\n![aichat-session](https://github.com/user-attachments/assets/56583566-0f43-435f-95b3-730ae55df031)\n\n> The left side uses a session, while the right side does not use a session.\n\n### Macro\n\nStreamline repetitive tasks by combining a series of REPL commands into a custom macro.\n\n![aichat-macro](https://github.com/user-attachments/assets/23c2a08f-5bd7-4bf3-817c-c484aa74a651)\n\n### RAG\n\nIntegrate external documents into your LLM conversations for more accurate and contextually relevant responses.\n\n![aichat-rag](https://github.com/user-attachments/assets/359f0cb8-ee37-432f-a89f-96a2ebab01f6)\n\n### Function Calling\n\nFunction calling supercharges LLMs by connecting them to external tools and data sources. This unlocks a world of possibilities, enabling LLMs to go beyond their core capabilities and tackle a wider range of tasks.\n\nWe have created a new repository [https://github.com/sigoden/llm-functions](https://github.com/sigoden/llm-functions) to help you make the most of this feature.\n\n#### AI Tools & MCP\n\nIntegrate external tools to automate tasks, retrieve information, and perform actions directly within your workflow.\n\n![aichat-tool](https://github.com/user-attachments/assets/7459a111-7258-4ef0-a2dd-624d0f1b4f92)\n\n#### AI Agents (CLI version of OpenAI GPTs)\n\nAI Agent = Instructions (Prompt) + Tools (Function Callings) + Documents (RAG).\n\n![aichat-agent](https://github.com/user-attachments/assets/0b7e687d-e642-4e8a-b1c1-d2d9b2da2b6b)\n\n### Local Server Capabilities\n\nAIChat includes a lightweight built-in HTTP server for easy deployment.\n\n```\n$ aichat --serve\nChat Completions API: http://127.0.0.1:8000/v1/chat/completions\nEmbeddings API:       http://127.0.0.1:8000/v1/embeddings\nRerank API:           http://127.0.0.1:8000/v1/rerank\nLLM Playground:       http://127.0.0.1:8000/playground\nLLM Arena:            http://127.0.0.1:8000/arena?num=2\n```\n\n#### Proxy LLM APIs\n\nThe LLM Arena is a web-based platform where you can compare different LLMs side-by-side. \n\nTest with curl:\n\n```sh\ncurl -X POST -H \"Content-Type: application/json\" -d '{\n  \"model\":\"claude:claude-3-5-sonnet-20240620\",\n  \"messages\":[{\"role\":\"user\",\"content\":\"hello\"}], \n  \"stream\":true\n}' http://127.0.0.1:8000/v1/chat/completions\n```\n\n#### LLM Playground\n\nA web application to interact with supported LLMs directly from your browser.\n\n![aichat-llm-playground](https://github.com/user-attachments/assets/aab1e124-1274-4452-b703-ef15cda55439)\n\n#### LLM Arena\n\nA web platform to compare different LLMs side-by-side.\n\n![aichat-llm-arena](https://github.com/user-attachments/assets/edabba53-a1ef-4817-9153-38542ffbfec6)\n\n## Custom Themes\n\nAIChat supports custom dark and light themes, which highlight response text and code blocks.\n\n![aichat-themes](https://github.com/sigoden/aichat/assets/4012553/29fa8b79-031e-405d-9caa-70d24fa0acf8)\n\n## Documentation\n\n- [Chat-REPL Guide](https://github.com/sigoden/aichat/wiki/Chat-REPL-Guide)\n- [Command-Line Guide](https://github.com/sigoden/aichat/wiki/Command-Line-Guide)\n- [Role Guide](https://github.com/sigoden/aichat/wiki/Role-Guide)\n- [Macro Guide](https://github.com/sigoden/aichat/wiki/Macro-Guide)\n- [RAG Guide](https://github.com/sigoden/aichat/wiki/RAG-Guide)\n- [Environment Variables](https://github.com/sigoden/aichat/wiki/Environment-Variables)\n- [Configuration Guide](https://github.com/sigoden/aichat/wiki/Configuration-Guide)\n- [Custom Theme](https://github.com/sigoden/aichat/wiki/Custom-Theme)\n- [Custom REPL Prompt](https://github.com/sigoden/aichat/wiki/Custom-REPL-Prompt)\n- [FAQ](https://github.com/sigoden/aichat/wiki/FAQ)\n\n## License\n\nCopyright (c) 2023-2025 aichat-developers.\n\nAIChat is made available under the terms of either the MIT License or the Apache License 2.0, at your option.\n\nSee the LICENSE-APACHE and LICENSE-MIT files for license details."
  },
  {
    "path": "assets/arena.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n\n<head>\n  <meta charset=\"UTF-8\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n  <link rel=\"icon\" href=\"data:;base64,iVBORw0KGgo=\">\n  <title>AIChat LLM Arena</title>\n  <link rel=\"stylesheet\" href=\"//unpkg.com/katex@0.16.11/dist/katex.min.css\">\n  <link rel=\"stylesheet\" href=\"//unpkg.com/github-markdown-css@5.8.1/github-markdown.css\">\n  <link rel=\"stylesheet\" href=\"//unpkg.com/@highlightjs/cdn-assets@11.10.0/styles/github-dark.min.css\"\n    media=\"screen and (prefers-color-scheme: dark)\">\n  <link rel=\"stylesheet\" href=\"//unpkg.com/@highlightjs/cdn-assets@11.10.0/styles/github.min.css\"\n    media=\"screen and (prefers-color-scheme: light)\">\n  <script src=\"//unpkg.com/@highlightjs/cdn-assets@11.10.0/highlight.min.js\" defer></script>\n  <script src=\"//unpkg.com/marked@15.0.3/lib/marked.umd.js\" defer></script>\n  <script src=\"//unpkg.com/katex@0.16.11/dist/katex.min.js\" defer></script>\n  <script src=\"//unpkg.com/@sigodenjs/marked-katex-extension@1.0.0/lib/index.umd.js\" defer></script>\n  <script src=\"//unpkg.com/alpinejs@3.14.6/dist/cdn.min.js\" defer></script>\n  <style>\n    :root {\n      --fg-primary: #1652f1;\n      --fg-default: black;\n      --bg-primary: white;\n      --bg-default: #f9f9f9;\n      --bg-toast: rgba(0, 0, 0, 0.7);\n      --border-color: #c3c3c3;\n    }\n\n    [x-cloak] {\n      display: none !important;\n    }\n\n    html {\n      font-family: Noto Sans, SF Pro SC, SF Pro Text, SF Pro Icons, PingFang SC, Helvetica Neue, Helvetica, Arial, sans-serif\n    }\n\n    body,\n    div {\n      padding: 0;\n      margin: 0;\n      box-sizing: border-box;\n    }\n\n    textarea,\n    input,\n    select,\n    option {\n      color: var(--fg-default);\n      background-color: var(--bg-primary);\n    }\n\n    body {\n      font-family: Arial, sans-serif;\n      font-size: 1rem;\n      display: flex;\n      height: 100vh;\n      color: var(--fg-default);\n      background-color: var(--bg-default);\n    }\n\n    .container {\n      display: flex;\n      flex-direction: column;\n      background-color: var(--bg-primary);\n      width: 100%;\n    }\n\n    .chats {\n      display: flex;\n      flex-direction: row;\n      flex-grow: 1;\n      width: 100%;\n    }\n\n    .chat-panel {\n      display: flex;\n      flex-direction: column;\n      width: 100%;\n    }\n\n    .chat-header {\n      display: flex;\n      padding: 0.5rem;\n      flex-direction: row;\n      border-bottom: 1px solid var(--border-color);\n    }\n\n    .chat-header select {\n      width: 100%;\n      outline: none;\n      font-size: 1.25rem;\n      border: none;\n    }\n\n    .chat-body {\n      display: flex;\n      flex-direction: column;\n      flex-grow: 1;\n      overflow-x: hidden;\n      overflow-y: auto;\n    }\n\n    .chat-message {\n      display: flex;\n      padding: 0.7rem;\n      margin-bottom: 0.7rem;\n    }\n\n    .chat-avatar svg {\n      width: 1.25rem;\n      height: 1.25rem;\n      border-radius: 50%;\n    }\n\n    .chat-message-content {\n      position: relative;\n      display: flex;\n      flex-direction: column;\n      width: calc(100% - 1rem);\n      margin-top: -2px;\n      padding-left: 0.625rem;\n      flex-grow: 1;\n    }\n\n    .chat-message-content .error {\n      color: red;\n      background: none;\n      padding: 0;\n    }\n\n    .chat-message-content .message-text {\n      white-space: pre-wrap;\n      padding-top: 0.2rem;\n    }\n\n    .message-image-bar {\n      display: flex;\n      flex-direction: row;\n      overflow-x: auto;\n    }\n\n    .message-image {\n      margin: 0.25rem;\n    }\n\n    .message-image img {\n      width: 10rem;\n      height: 10rem;\n      object-fit: cover;\n    }\n\n    .markdown-body {\n      display: flex;\n      width: 100%;\n      padding: 0;\n      flex-direction: column;\n      background-color: var(--bg-primary);\n    }\n\n    .markdown-body:first-child {\n      margin-top: 0;\n      padding-top: 0;\n    }\n\n    .markdown-body pre {\n      overflow-x: auto;\n      word-wrap: break-word;\n    }\n\n    .code-block {\n      position: relative;\n      width: 100%;\n    }\n\n    .message-toolbox {\n      display: flex;\n      position: absolute;\n      bottom: -1.4rem;\n    }\n\n    .copy-message-btn,\n    .regenerate-message-btn,\n    .tts-message-btn {\n      top: 0.7rem;\n      right: 0.7rem;\n      cursor: pointer;\n      font-size: 0.9rem;\n      padding-right: 4px;\n    }\n\n    .copy-message-btn svg,\n    .regenerate-message-btn svg,\n    .tts-message-btn svg {\n      width: 1rem;\n      height: 1rem;\n    }\n\n    .copy-code-btn {\n      position: absolute;\n      top: 0.7rem;\n      right: 0.7rem;\n      cursor: pointer;\n      font-size: 0.9rem;\n    }\n\n    .copy-code-btn svg {\n      width: 1rem;\n      height: 1rem;\n    }\n\n    .scroll-to-bottom-btn {\n      position: absolute;\n      text-align: center;\n      cursor: pointer;\n      width: 1.5rem;\n      height: 1.5rem;\n      border-radius: 0.75rem;\n      background-color: var(--bg-primary);\n    }\n\n    .scroll-to-bottom-btn svg {\n      width: 1.5rem;\n      height: 1.5rem;\n      border-radius: 50%;\n    }\n\n    .input-panel {\n      position: relative;\n      border-top: 1px solid var(--border-color);\n    }\n\n    .input-panel-inner {\n      margin: 1rem;\n      padding: 0.5rem;\n      border: 1px solid var(--border-color);\n      border-radius: 1rem;\n    }\n\n    .input-panel-inner textarea {\n      width: 100%;\n      font-size: 1rem;\n      padding: 0.4rem;\n      box-sizing: border-box;\n      border: none;\n      outline: none;\n      resize: none;\n      max-height: 500px;\n      overflow-x: hidden;\n      overflow-y: auto;\n    }\n\n    .input-toolbox {\n      position: absolute;\n      display: flex;\n      right: 1.875rem;\n      font-size: 1rem;\n      bottom: 1.875rem;\n      cursor: pointer;\n    }\n\n    .input-toolbox svg {\n      width: 1.875rem;\n      height: 1.875rem;\n      fill: var(--fg-default);\n    }\n\n    .image-btn {\n      position: relative;\n      display: inline-block;\n      margin-right: 0.5rem;\n    }\n\n    .image-btn input[type=\"file\"] {\n      position: absolute;\n      top: 0;\n      left: 0;\n      width: 100%;\n      height: 100%;\n      opacity: 0;\n      cursor: pointer;\n    }\n\n    .input-image-bar {\n      display: flex;\n      flex-direction: row;\n      width: 100%;\n      overflow-x: auto;\n    }\n\n    .input-image-item {\n      display: flex;\n      margin: 0.25rem;\n      width: 5rem;\n      position: relative;\n    }\n\n    .input-image-item img {\n      width: 5rem;\n      height: 5rem;\n      object-fit: cover;\n    }\n\n    .image-remove-btn {\n      font-size: 1rem;\n      margin-left: -0.8rem;\n      cursor: pointer;\n    }\n\n    .image-remove-btn {\n      width: 1rem;\n      height: 1rem;\n    }\n\n    .input-btn.disabled {\n      opacity: 0.3;\n    }\n\n    .spinner {\n      width: 1.1rem;\n      height: 1.1rem;\n      margin-top: 3px;\n      border: 2px solid var(--fg-default);\n      border-bottom-color: transparent;\n      border-radius: 50%;\n      display: inline-block;\n      animation: spinner-rotation 1s linear infinite;\n    }\n\n    .toast {\n      display: none;\n      position: fixed;\n      top: 2px;\n      left: 50%;\n      text-align: center;\n      transform: translate(-50%, 0);\n      background-color: var(--bg-toast);\n      color: var(--bg-primary);\n      padding: 0.5rem;\n      border-radius: 0.3rem;\n      z-index: 9999;\n    }\n\n    @keyframes spinner-rotation {\n      0% {\n        transform: rotate(0deg);\n      }\n\n      100% {\n        transform: rotate(360deg);\n      }\n    }\n\n    @media (prefers-color-scheme: dark) {\n      :root {\n        --fg-primary: #1652f1;\n        --fg-default: white;\n        --bg-primary: black;\n        --bg-default: #121212;\n        --bg-toast: rgba(255, 255, 255, 0.7);\n        --border-color: #3c3c3c;\n      }\n    }\n\n    @media screen and (max-width: 768px) {\n      body {\n        height: calc(100vh - 56px);\n        height: 100dvh;\n      }\n\n      .container {\n        padding: 3px;\n      }\n\n      .chat-header {\n        padding: 0.6rem;\n      }\n\n      .chat-header select {\n        font-size: 1rem;\n      }\n\n      .chat-body {\n        padding: 0.6rem;\n      }\n\n      .input-panel-inner {\n        margin: 0.5rem;\n      }\n    }\n  </style>\n</head>\n\n<body>\n  <div class=\"container\" x-data=\"app\">\n    <div class=\"chats\">\n      <template x-for=\"(chat, index) in chats\" :key=\"index\">\n        <div class=\"chat-panel\">\n          <div class=\"chat-header\">\n            <select x-cloak id=\"model\" x-model=\"chat.model\" @change=\"handleModelChange\">\n              <template x-for=\"model in chatModels\" :key=\"model.id\">\n                <option :value=\"model.id\" :selected=\"model.id == chat.model\" x-text=\"model.id\"></option>\n              </template>\n            </select>\n          </div>\n          <div class=\"chat-body\" :id=\"'chat-body-' + index\" @scroll=\"(event) => handleScrollChatBody(event, index)\">\n            <template x-for=\"(message, messageIndex) in chat.messages\" :key=\"message.id\">\n              <div class=\"chat-message\" @mouseover=\"chat.hoveredMessageIndex = messageIndex\"\n                @mouseleave=\"chat.messageHoveredIndex = null\">\n                <div class=\"chat-avatar\" :class=\"message.role == 'user' ? 'chat-avatar user' : 'chat-avatar assistant'\">\n                  <template x-if=\"message.role == 'user'\">\n                    <svg fill=\"currentColor\" viewBox=\"0 0 16 16\">\n                      <path d=\"M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0\" />\n                      <path fill-rule=\"evenodd\"\n                        d=\"M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8m8-7a7 7 0 0 0-5.468 11.37C3.242 11.226 4.805 10 8 10s4.757 1.225 5.468 2.37A7 7 0 0 0 8 1\" />\n                    </svg>\n                  </template>\n                  <template x-if=\"message.role == 'assistant'\">\n                    <svg fill=\"currentColor\" viewBox=\"0 0 16 16\">\n                      <path\n                        d=\"M6 12.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 0 1h-3a.5.5 0 0 1-.5-.5M3 8.062C3 6.76 4.235 5.765 5.53 5.886a26.6 26.6 0 0 0 4.94 0C11.765 5.765 13 6.76 13 8.062v1.157a.93.93 0 0 1-.765.935c-.845.147-2.34.346-4.235.346s-3.39-.2-4.235-.346A.93.93 0 0 1 3 9.219zm4.542-.827a.25.25 0 0 0-.217.068l-.92.9a25 25 0 0 1-1.871-.183.25.25 0 0 0-.068.495c.55.076 1.232.149 2.02.193a.25.25 0 0 0 .189-.071l.754-.736.847 1.71a.25.25 0 0 0 .404.062l.932-.97a25 25 0 0 0 1.922-.188.25.25 0 0 0-.068-.495c-.538.074-1.207.145-1.98.189a.25.25 0 0 0-.166.076l-.754.785-.842-1.7a.25.25 0 0 0-.182-.135\" />\n                      <path\n                        d=\"M8.5 1.866a1 1 0 1 0-1 0V3h-2A4.5 4.5 0 0 0 1 7.5V8a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1v1a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-1a1 1 0 0 0 1-1V9a1 1 0 0 0-1-1v-.5A4.5 4.5 0 0 0 10.5 3h-2zM14 7.5V13a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V7.5A3.5 3.5 0 0 1 5.5 4h5A3.5 3.5 0 0 1 14 7.5\" />\n                    </svg>\n                  </template>\n                </div>\n                <div class=\"chat-message-content\">\n                  <!-- message -->\n                  <template x-if=\"message.role == 'assistant' && message.html\">\n                    <div class=\"markdown-body\" x-html=\"message.html\"></div>\n                  </template>\n                  <template x-if=\"message.role == 'assistant' && message.state == 'loading'\">\n                    <div class=\"spinner\"></div>\n                  </template>\n                  <template x-if=\"message.role == 'user' && Array.isArray(message.content)\">\n                    <div class=\"message-text-images\">\n                      <template x-if=\"message.content[0].text\">\n                        <div class=\"message-text\" x-text=\"message.content[0].text\"></div>\n                      </template>\n                      <div class=\"message-image-bar\">\n                        <template x-for=\"part in message.content\">\n                          <template x-if=\"part.type == 'image_url'\">\n                            <div class=\"message-image\">\n                              <img :src=\"part.image_url.url\" alt=\"Image Message Part\">\n                            </div>\n                          </template>\n                        </template>\n                      </div>\n                    </div>\n                  </template>\n                  <template\n                    x-if=\"message.role == 'user' && Object.prototype.toString.call(message.content) == '[object String]'\">\n                    <div class=\"message-text\" x-text=\"message.content\"></div>\n                  </template>\n                  <!-- toolbox -->\n                  <template x-if=\"messageIndex == chat.hoveredMessageIndex\">\n                    <div class=\"message-toolbox\">\n                      <div class=\"copy-message-btn\" @click=\"handleCopyMessage(message.content)\" title=\"Copy\">\n                        <svg fill=\"currentColor\" viewBox=\"0 0 16 16\">\n                          <path fill-rule=\"evenodd\"\n                            d=\"M4 2a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2zm2-1a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1zM2 5a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1v-1h1v1a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h1v1z\" />\n                        </svg>\n                      </div>\n                      <template\n                        x-if=\"messageIndex == chat.messages.length - 1 && (message.state == 'succeed' || message.state == 'failed')\">\n                        <div class=\"regenerate-message-btn\" @click=\"(event) => handleRegenerateMessage(index)\"\n                          title=\"Regenerate\">\n                          <svg fill=\"currentColor\" viewBox=\"0 0 16 16\">\n                            <path fill-rule=\"evenodd\"\n                              d=\"M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2z\" />\n                            <path\n                              d=\"M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466\" />\n                          </svg>\n                        </div>\n                      </template>\n                      <template x-if=\"message.state == 'succeed' && !!window.speechSynthesis\">\n                        <div class=\"tts-message-btn\" @click=\"handleTTSMessage(message.content)\" title=\"Text to speech\">\n                          <svg fill=\"currentColor\" viewBox=\"0 0 16 16\">\n                            <path\n                              d=\"M11.536 14.01A8.47 8.47 0 0 0 14.026 8a8.47 8.47 0 0 0-2.49-6.01l-.708.707A7.48 7.48 0 0 1 13.025 8c0 2.071-.84 3.946-2.197 5.303z\" />\n                            <path\n                              d=\"M10.121 12.596A6.48 6.48 0 0 0 12.025 8a6.48 6.48 0 0 0-1.904-4.596l-.707.707A5.48 5.48 0 0 1 11.025 8a5.48 5.48 0 0 1-1.61 3.89z\" />\n                            <path\n                              d=\"M10.025 8a4.5 4.5 0 0 1-1.318 3.182L8 10.475A3.5 3.5 0 0 0 9.025 8c0-.966-.392-1.841-1.025-2.475l.707-.707A4.5 4.5 0 0 1 10.025 8M7 4a.5.5 0 0 0-.812-.39L3.825 5.5H1.5A.5.5 0 0 0 1 6v4a.5.5 0 0 0 .5.5h2.325l2.363 1.89A.5.5 0 0 0 7 12zM4.312 6.39 6 5.04v5.92L4.312 9.61A.5.5 0 0 0 4 9.5H2v-3h2a.5.5 0 0 0 .312-.11\" />\n                          </svg>\n                        </div>\n                      </template>\n                    </div>\n                  </template>\n                </div>\n              </div>\n            </template>\n          </div>\n          <div class=\"scroll-to-bottom-btn\" x-cloak x-show=\"chat.isShowScrollToBottomBtn\"\n            @click=\"() => handleScrollToBottom(index)\">\n            <svg fill=\"currentColor\" viewBox=\"0 0 16 16\">\n              <path fill-rule=\"evenodd\"\n                d=\"M1 8a7 7 0 1 0 14 0A7 7 0 0 0 1 8m15 0A8 8 0 1 1 0 8a8 8 0 0 1 16 0M8.5 4.5a.5.5 0 0 0-1 0v5.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293z\" />\n            </svg>\n          </div>\n        </div>\n      </template>\n    </div>\n    <div class=\"input-panel\">\n      <div class=\"input-panel-inner\">\n        <textarea id=\"chat-input\" x-model=\"input\" x-ref=\"input\" @keydown.enter=\"handleEnterKeydown\"\n          placeholder=\"Ask Anything\" autofocus></textarea>\n        <div class=\"input-image-bar\" x-show=\"images.length > 0\">\n          <template x-for=\"(image, index) in images\">\n            <div class=\"input-image-item\">\n              <img :src=\"image\" alt=\"Preview image\">\n              <div class=\"image-remove-btn\" @click=\"images.splice(index, 1);\">\n                <svg fill=\"currentColor\" viewBox=\"0 0 16 16\">\n                  <path\n                    d=\"M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5m2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5m3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0z\" />\n                  <path\n                    d=\"M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4zM2.5 3h11V2h-11z\" />\n                </svg>\n              </div>\n            </div>\n          </template>\n        </div>\n        <template x-if=\"asking > 0\">\n          <div class=\"input-toolbox\">\n            <div class=\"input-btn\" @click=\"handleCancelAsk\">\n              <svg fill=\"currentColor\" viewBox=\"0 0 16 16\">\n                <path d=\"M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14m0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16\" />\n                <path\n                  d=\"M5 6.5A1.5 1.5 0 0 1 6.5 5h3A1.5 1.5 0 0 1 11 6.5v3A1.5 1.5 0 0 1 9.5 11h-3A1.5 1.5 0 0 1 5 9.5z\" />\n              </svg>\n            </div>\n          </div>\n        </template>\n        <template x-if=\"asking == 0\">\n          <div class=\"input-toolbox\">\n            <div class=\"image-btn\" x-show=\"supportsVision\">\n              <input type=\"file\" multiple accept=\".jpg,.jpeg,.png,.webp\" @change=\"handleImageUpload\">\n              <svg fill=\"currentColor\" viewBox=\"0 0 16 16\">\n                <path d=\"M6.002 5.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0\" />\n                <path\n                  d=\"M2.002 1a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V3a2 2 0 0 0-2-2zm12 1a1 1 0 0 1 1 1v6.5l-3.777-1.947a.5.5 0 0 0-.577.093l-3.71 3.71-2.66-1.772a.5.5 0 0 0-.63.062L1.002 12V3a1 1 0 0 1 1-1z\" />\n              </svg>\n            </div>\n            <div class=\"input-btn\" :class=\"(input.trim() || images.length > 0) ? 'input-btn' : 'input-btn disabled'\"\n              @click=\"handleAsk\">\n              <svg fill=\"currentColor\" viewBox=\"0 0 16 16\">\n                <path\n                  d=\"M2 16a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2zm6.5-4.5V5.707l2.146 2.147a.5.5 0 0 0 .708-.708l-3-3a.5.5 0 0 0-.708 0l-3 3a.5.5 0 1 0 .708.708L7.5 5.707V11.5a.5.5 0 0 0 1 0\" />\n              </svg>\n            </div>\n          </div>\n        </template>\n      </div>\n    </div>\n    <div id=\"toast\" class=\"toast\"></div>\n  </div>\n  <script>\n    const QUERY = parseQueryString();\n    const NUM = parseInt(QUERY.num) || 2\n    const API_BASE = QUERY.api_base || \"./v1\";\n    const API_KEY = QUERY.api_key || \"\";\n    const CHAT_COMPLETIONS_URL = API_BASE + \"/chat/completions\";\n    const MODELS_API = API_BASE + \"/models\";\n\n    document.addEventListener(\"alpine:init\", () => {\n      setupMarked();\n      setupApp();\n    });\n\n    function setupApp() {\n      let $inputPanel = document.querySelector('.input-panel');\n      let $chatPanels = [];\n      let $scrollToBottomBtns = [];\n      let msgIdx = 0;\n\n      Alpine.data(\"app\", () => ({\n        chatModels: [],\n        input: \"\",\n        images: [],\n        asking: 0,\n        chats: Array.from(Array(NUM)).map(_ => ({\n          model: \"\",\n          messages: [],\n          hoveredMessageIndex: null,\n          askAbortController: null,\n          shouldScrollChatBodyToBottom: true,\n          isShowScrollToBottomBtn: false,\n        })),\n\n        async init() {\n          try {\n            const models = await fetchJSON(MODELS_API);\n            this.chatModels = models.filter(v => !v.type || v.type === \"chat\");\n          } catch (err) {\n            toast(\"No available model\");\n            console.error(\"Failed to load models\", err);\n          }\n          let models = []\n          if (QUERY.models) {\n            models = QUERY.models.split(\",\");\n          }\n          $chatPanels = document.querySelectorAll('.chat-panel');\n          $scrollToBottomBtns = document.querySelectorAll('.scroll-to-bottom-btn');\n          const offsets = calculateOffsets(NUM);\n          for (let i = 0; i < NUM; i++) {\n            this.chats[i].model = models[i] || \"default\";\n            $chatPanels[i].style.width = (100 / NUM) + '%';\n            if (i > 0) {\n              $chatPanels[i].style.borderLeft = '1px solid var(--border-color)';\n            }\n            $scrollToBottomBtns[i].style.left = offsets[i];\n          }\n          this.$refs.input.addEventListener(\"paste\", (e) => this.handlePaste(e));\n          this.$watch(\"input\", () => this.autosizeInput(this.$refs.input));\n          new ResizeObserver(() => {\n            this.autoHeightChatPanel();\n          }).observe($inputPanel)\n        },\n\n        get supportsVision() {\n          return this.chats.every(v => !!retrieveModel(this.chatModels, v.model)?.supports_vision)\n        },\n\n        handleAsk() {\n          const isEmptyInput = this.input.trim() === \"\";\n          const isEmptyImage = this.images.length === 0;\n          if (this.asking > 0 || (isEmptyImage && isEmptyInput)) {\n            return;\n          }\n\n          for (let index = 0; index < this.chats.length; index++) {\n            const chat = this.chats[index];\n            if (isEmptyImage) {\n              chat.messages.push({\n                id: msgIdx++,\n                role: \"user\",\n                content: this.input,\n              });\n            } else {\n              const parts = [];\n              if (!isEmptyInput) {\n                parts.push({ type: \"text\", text: this.input });\n              }\n              for (const image of this.images) {\n                parts.push({ type: \"image_url\", image_url: { url: image } });\n              }\n              chat.messages.push({\n                id: msgIdx++,\n                role: \"user\",\n                content: parts,\n              })\n            }\n            chat.messages.push({\n              id: msgIdx++,\n              role: \"assistant\",\n              content: \"\",\n              state: \"loading\", // streaming, succeed, failed\n              error: \"\",\n              html: \"\",\n            });\n          }\n\n          for (let index = 0; index < this.chats.length; index++) {\n            this.asking++;\n            this.ask(index);\n          }\n\n          this.input = \"\";\n          this.images = [];\n        },\n\n        handleRegenerateMessage(index) {\n          const chat = this.chats[index];\n          const lastIndex = chat.messages.length - 1;\n          if (lastIndex !== chat.hoveredMessageIndex) {\n            return\n          }\n          let lastMessage = chat.messages[lastIndex];\n          lastMessage.content = \"\";\n          lastMessage.state = \"loading\";\n          lastMessage.error = \"\";\n          lastMessage.html = \"\";\n          this.asking++;\n          this.ask(index);\n        },\n\n        /**\n         * @param {string} messageToUtter\n         */\n        handleTTSMessage(messageToUtter) {\n          if (!!window.speechSynthesis) {\n            if (window.speechSynthesis.speaking || window.speechSynthesis.pending) {\n              window.speechSynthesis.cancel();\n            } else {\n              let utterance = new SpeechSynthesisUtterance(messageToUtter);\n              window.speechSynthesis.speak(utterance);\n            }\n          }\n        },\n\n        handleCancelAsk() {\n          for (const chat of this.chats) {\n            chat.askAbortController?.abort();\n          }\n        },\n\n        handleModelChange() {\n          this.updateUrl();\n        },\n\n        handleScrollChatBody(event, index) {\n          const chat = this.chats[index];\n          const $chatBody = event.target;\n          const { scrollTop, clientHeight, scrollHeight, _prevScrollTop = 0 } = $chatBody;\n          if (scrollTop + clientHeight > scrollHeight - 5) {\n            chat.isShowScrollToBottomBtn = false;\n            chat.shouldScrollChatBodyToBottom = true;\n          }\n          if (scrollHeight > clientHeight && _prevScrollTop > 1 && _prevScrollTop > scrollTop + 1) {\n            chat.shouldScrollChatBodyToBottom = false;\n            chat.isShowScrollToBottomBtn = true;\n          }\n          $chatBody._prevScrollTop = scrollTop;\n        },\n\n        handleScrollToBottom(index) {\n          const chat = this.chats[index];\n          const $chatBody = document.querySelector('#chat-body-' + index);\n          $chatBody.scrollTop = $chatBody.scrollHeight;\n          chat.isShowScrollToBottomBtn = false;\n          chat.shouldScrollChatBodyToBottom = true;\n        },\n\n        handleEnterKeydown(event) {\n          if (event.shiftKey) {\n            return;\n          }\n          event.preventDefault();\n          this.handleAsk();\n        },\n\n        handleCopyCode(event) {\n          const $btn = event.target;\n          const $code = $btn.closest('.code-block').querySelector(\"code\");\n          if ($code) {\n            const range = document.createRange();\n            range.selectNodeContents($code);\n            window.getSelection().removeAllRanges();\n            window.getSelection().addRange(range);\n            document.execCommand('copy');\n            window.getSelection().removeAllRanges();\n            toast(\"Copied Code\");\n          }\n        },\n\n        handleCopyMessage(content) {\n          if (Array.isArray(content)) {\n            content = content.map(v => v.text || \"\").join(\"\");\n          }\n\n          const $tempTextArea = document.createElement(\"textarea\");\n          $tempTextArea.value = content;\n          document.body.appendChild($tempTextArea);\n          $tempTextArea.select();\n          $tempTextArea.setSelectionRange(0, 99999); // For mobile devices\n          document.execCommand(\"copy\");\n          document.body.removeChild($tempTextArea);\n          toast(\"Copied Message\")\n        },\n\n        async handleImageUpload(event) {\n          const files = event.target.files;\n          if (!files || files.length === 0) {\n            return;\n          }\n          const urls = await Promise.all(Array.from(files).map(file => convertImageToDataURL(file)));\n          this.images.push(...urls);\n          event.target.value = \"\";\n        },\n\n        async handlePaste(event) {\n          const files = Array.from(event.clipboardData.items).filter(v => v.type.startsWith('image/')).map(v => v.getAsFile());\n          const urls = await Promise.all(files.map(file => convertImageToDataURL(file)));\n          this.images.push(...urls);\n        },\n\n        updateUrl() {\n          const newUrl = new URL(location.href);\n          const models = this.chats.map(v => v.model).join(\",\");\n          newUrl.searchParams.set(\"models\", models);\n          history.replaceState(null, '', newUrl.toString());\n        },\n\n        autoHeightChatPanel() {\n          const height = $inputPanel.offsetHeight;\n          for (let i = 0; i < this.chats.length; i++) {\n            $chatPanels[i].style.height = (window.innerHeight - height - 5) + \"px\";\n            $scrollToBottomBtns[i].style.bottom = (height + 20) + \"px\";\n          }\n        },\n\n        autoScrollChatBodyToBottom(index) {\n          const chat = this.chats[index];\n          if (chat.shouldScrollChatBodyToBottom) {\n            const $chatBody = document.querySelector('#chat-body-' + index);\n            if ($chatBody) {\n              $chatBody.scrollTop = $chatBody.scrollHeight;\n            }\n          }\n        },\n\n        autosizeInput($input) {\n          $input.style.height = 'auto';\n          $input.style.height = $input.scrollHeight + 'px';\n        },\n\n        async ask(index) {\n          const chat = this.chats[index];\n          chat.askAbortController = new AbortController();\n          chat.shouldScrollChatBodyToBottom = true;\n          this.$nextTick(() => {\n            this.autoScrollChatBodyToBottom(index);\n          });\n          const lastMessage = chat.messages[chat.messages.length - 1];\n          const body = this.buildBody(index);\n          let succeed = false;\n          try {\n            const stream = await fetchChatCompletions(CHAT_COMPLETIONS_URL, body, chat.askAbortController.signal)\n            for await (const chunk of stream) {\n              lastMessage.state = \"streaming\";\n              lastMessage.content += chunk?.choices[0]?.delta?.content || \"\";\n              lastMessage.html = renderMarkdown(lastMessage.content, lastMessage.error);\n              this.$nextTick(() => {\n                this.autoScrollChatBodyToBottom(index);\n              });\n            }\n            lastMessage.state = \"succeed\";\n            succeed = true;\n          } catch (err) {\n            lastMessage.state = \"failed\";\n            if (this.askAbortController?.signal?.aborted) {\n              lastMessage.error = \"\";\n            } else {\n              lastMessage.error = err?.message || err;\n            }\n            lastMessage.html = renderMarkdown(lastMessage.content, lastMessage.error);\n          }\n          this.asking--;\n        },\n\n        buildBody(index) {\n          const chat = this.chats[index];\n          const messages = [];\n          for ([userMessage, assistantMessage] of chunkArray(chat.messages, 2)) {\n            if (assistantMessage.state === \"failed\") {\n              continue;\n            } else if (assistantMessage.state === \"loading\") {\n              messages.push({\n                role: userMessage.role,\n                content: userMessage.content,\n              });\n            } else {\n              messages.push({\n                role: userMessage.role,\n                content: userMessage.content,\n              });\n              messages.push({\n                role: assistantMessage.role,\n                content: assistantMessage.content,\n              });\n            }\n          }\n          sanitizeMessages(messages);\n          const body = {\n            model: chat.model,\n            messages: messages,\n            stream: true,\n          };\n          const { max_output_token, require_max_tokens } = retrieveModel(this.chatModels, chat.model);\n          if (!body[\"max_tokens\"] && require_max_tokens) {\n            body[\"max_tokens\"] = max_output_token;\n          };\n          return body;\n        },\n      }));\n    }\n\n    async function fetchJSON(url) {\n      const res = await fetch(url, { headers: getHeaders() });\n      const data = await res.json()\n      return data.data;\n    }\n\n    async function* fetchChatCompletions(url, body, signal) {\n      const stream = body.stream;\n      const response = await fetch(url, {\n        method: \"POST\",\n        signal,\n        headers: getHeaders(),\n        body: JSON.stringify(body),\n      });\n\n      if (!response.ok) {\n        const error = await response.json();\n        throw error?.error || error;\n      }\n\n      if (!stream) {\n        const data = await response.json();\n        return data;\n      }\n      const reader = response.body.getReader();\n      const decoder = new TextDecoder();\n      let done = false;\n      let reamingChunkValue = \"\";\n\n      while (!done) {\n        if (signal?.aborted) {\n          reader.cancel();\n          break;\n        }\n        const { value, done: doneReading } = await reader.read();\n        done = doneReading;\n        const chunkValue = decoder.decode(value);\n        const lines = (reamingChunkValue + chunkValue).split(\"\\n\").filter(line => line.trim().length > 0);\n        reamingChunkValue = \"\";\n\n        for (let i = 0; i < lines.length; i++) {\n          const line = lines[i];\n          const message = line.replace(/^data: /, \"\");\n          if (message === \"[DONE]\") {\n            continue\n          }\n          try {\n            const parsed = JSON.parse(message);\n            yield parsed;\n          } catch {\n            if (i === lines.length - 1) {\n              reamingChunkValue += line;\n              break;\n            }\n          }\n        }\n      }\n    }\n\n    function getHeaders() {\n      const headers = {\n        \"content-type\": \"application/json\",\n      };\n      if (API_KEY) {\n        headers[\"authorization\"] = `Bearer ${API_KEY}`;\n      }\n      return headers\n    }\n\n    function retrieveModel(models, id) {\n      const model = models.find(model => model.id === id);\n      if (!model) return {};\n      const max_output_token = model.max_output_tokens;\n      const supports_vision = !!model.supports_vision;\n      const require_max_tokens = !!model.require_max_tokens;\n      return {\n        id,\n        max_output_token,\n        supports_vision,\n        require_max_tokens,\n      }\n    }\n\n    function toast(text, duration = 2500) {\n      const $toast = document.getElementById(\"toast\");\n      clearTimeout($toast._timer);\n      $toast.textContent = text;\n      $toast.style.display = \"block\";\n      $toast._timer = setTimeout(function () {\n        $toast.style.display = \"none\";\n      }, duration);\n    }\n\n    function convertImageToDataURL(imageFile) {\n      return new Promise((resolve, reject) => {\n        if (!imageFile) {\n          reject(new Error(\"Please select an image file.\"));\n          return;\n        }\n\n        const reader = new FileReader();\n        reader.readAsDataURL(imageFile);\n        reader.onload = (event) => resolve(event.target.result);\n        reader.onerror = (error) => reject(error);\n      });\n    }\n\n    function sanitizeMessages(messages) {\n      let messagesLen = messages.length;\n      for (let i = 0; i < messagesLen; i++) {\n        const message = messages[i];\n        if (typeof message.content === \"string\" && message.role === \"assistant\" && i !== messagesLen - 1) {\n          message.content = stripThinkTag(message.content);\n        }\n      }\n    }\n\n    function stripThinkTag(text) {\n      return text.replace(/^\\s*<think>([\\s\\S]*?)<\\/think>(\\s*|$)/g, '')\n    }\n\n    function setupMarked() {\n      const renderer = {\n        code({ text, lang }) {\n          const validLang = !!(lang && hljs.getLanguage(lang));\n          const highlighted = validLang\n            ? hljs.highlight(text, { language: lang }).value\n            : escapeForHTML(text);\n\n          return `<div class=\"code-block\">\n        <pre><code class=\"hljs ${lang}\">${highlighted}</code></pre>\n  <div class=\"copy-code-btn\" @click=\"handleCopyCode\" title=\"Copy code\">\n    <svg fill=\"currentColor\" viewBox=\"0 0 16 16\">\n      <path fill-rule=\"evenodd\" d=\"M4 2a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2zm2-1a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1zM2 5a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1v-1h1v1a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h1v1z\"/>\n    </svg>\n  </div>\n</div>`;\n        }\n      };\n      const thinkExtension = {\n        name: 'think',\n        level: 'block',\n        start(src) {\n          const match = /^(\\s*)<think>/.exec(src);\n          if (match) {\n            return match[1].length\n          } else {\n            return -1;\n          }\n        },\n        tokenizer(src, tokens) {\n          const rule = /^\\s*<think>([\\s\\S]*?)(<\\/think>|$)/;\n          const match = rule.exec(src);\n          if (match) {\n            return {\n              type: 'think',\n              raw: match[0],\n              text: match[1].trim(),\n            };\n          }\n        },\n        renderer(token) {\n          const text = '<p>' + token.text.trim().replace(/\\n+/g, '</p><p>') + '</p>';\n          return `<details open class=\"think\">\n            <summary>Deeply thought</summary>\n            <blockquote>${text}</blockquote>\n          </details>`;\n        },\n      };\n      marked.use({ renderer });\n      marked.use(markedKatex({ throwOnError: false, inlineTolerantNoSpace: true }));\n      marked.use({ extensions: [thinkExtension] })\n    }\n\n    function escapeForHTML(input) {\n      const escapeMap = {\n        \"&\": \"&amp;\",\n        \"<\": \"&lt;\",\n        \">\": \"&gt;\",\n        '\"': \"&quot;\",\n        \"'\": \"&#39;\"\n      };\n\n      return input.replace(/([&<>'\"])/g, char => escapeMap[char]);\n    }\n\n    function parseQueryString() {\n      const params = new URLSearchParams(location.search);\n      const queryObject = {};\n      params.forEach((value, key) => {\n        queryObject[key] = value;\n      });\n      return queryObject;\n    }\n\n    function chunkArray(array, chunkSize) {\n      const chunks = [];\n      for (let i = 0; i < array.length; i += chunkSize) {\n        chunks.push(array.slice(i, i + chunkSize));\n      }\n      return chunks;\n    }\n\n    function renderMarkdown(text, error = '') {\n      return marked.marked(text) + (error ? `<pre class=\"error\">${error}</pre>` : '');\n    }\n\n    function calculateOffsets(pieces) {\n      const offsets = [];\n      for (let i = 1; i <= pieces; i++) {\n        const offset = ((i - 0.5) / pieces) * 100;\n        offsets.push(`${offset.toFixed(1)}%`);\n      }\n      return offsets;\n    }\n  </script>\n</body>\n\n</html>"
  },
  {
    "path": "assets/playground.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n\n<head>\n  <meta charset=\"UTF-8\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n  <link rel=\"icon\" href=\"data:;base64,iVBORw0KGgo=\">\n  <title>AIChat LLM Playground</title>\n  <link rel=\"stylesheet\" href=\"//unpkg.com/katex@0.16.11/dist/katex.min.css\">\n  <link rel=\"stylesheet\" href=\"//unpkg.com/github-markdown-css@5.8.1/github-markdown.css\">\n  <link rel=\"stylesheet\" href=\"//unpkg.com/@highlightjs/cdn-assets@11.10.0/styles/github-dark.min.css\"\n    media=\"screen and (prefers-color-scheme: dark)\">\n  <link rel=\"stylesheet\" href=\"//unpkg.com/@highlightjs/cdn-assets@11.10.0/styles/github.min.css\"\n    media=\"screen and (prefers-color-scheme: light)\">\n  <script src=\"//unpkg.com/@highlightjs/cdn-assets@11.10.0/highlight.min.js\" defer></script>\n  <script src=\"//unpkg.com/marked@15.0.3/lib/marked.umd.js\" defer></script>\n  <script src=\"//unpkg.com/katex@0.16.11/dist/katex.min.js\" defer></script>\n  <script src=\"//unpkg.com/@sigodenjs/marked-katex-extension@1.0.0/lib/index.umd.js\" defer></script>\n  <script src=\"//unpkg.com/alpinejs@3.14.6/dist/cdn.min.js\" defer></script>\n  <style>\n    :root {\n      --fg-primary: #1652f1;\n      --fg-default: black;\n      --bg-primary: white;\n      --bg-default: #f9f9f9;\n      --bg-toast: rgba(0, 0, 0, 0.7);\n      --bg-cover: rgba(0, 0, 0, 0.5);\n      --bg-hover: #f0f0f0;\n      --border-color: #c3c3c3;\n      --shadow-color: rgba(0, 0, 0, 0.1);\n    }\n\n    [x-cloak] {\n      display: none !important;\n    }\n\n    html {\n      font-family: Noto Sans, SF Pro SC, SF Pro Text, SF Pro Icons, PingFang SC, Helvetica Neue, Helvetica, Arial, sans-serif\n    }\n\n    body,\n    div {\n      padding: 0;\n      margin: 0;\n      box-sizing: border-box;\n    }\n\n    textarea,\n    input,\n    select,\n    option {\n      color: var(--fg-default);\n      background-color: var(--bg-primary);\n    }\n\n    body {\n      font-family: Arial, sans-serif;\n      font-size: 1rem;\n      display: flex;\n      height: 100vh;\n      color: var(--fg-default);\n      background-color: var(--bg-default);\n    }\n\n    .container {\n      width: 100%;\n      padding: 1.25rem;\n      box-sizing: border-box;\n      display: flex;\n    }\n\n    .sidebar {\n      width: 360px;\n      flex-shrink: 0;\n      margin-right: 1.25rem;\n      background-color: var(--bg-primary);\n      box-shadow: 0 0 0.3rem var(--shadow-color);\n      border-radius: 0.3rem;\n    }\n\n    .sidebar-header {\n      display: flex;\n      align-items: center;\n      padding: 1.25rem;\n    }\n\n    .sidebar-header .title {\n      font-size: 1.25rem;\n      font-weight: bold;\n    }\n\n    .sidebar-header .subtitle {\n      font-size: 0.8rem;\n      padding-top: 0.3rem;\n    }\n\n    .sidebar-right {\n      display: flex;\n      flex-direction: row;\n      margin-left: auto;\n      gap: 6px;\n    }\n\n    .sidebar-btn {\n      cursor: pointer;\n      width: 1.2rem;\n      height: 1.2rem;\n    }\n\n    .hide-sidebar-btn {\n      display: none;\n    }\n\n    .settings {\n      padding: 1.25rem;\n    }\n\n    .settings label {\n      display: block;\n      margin-bottom: 0.3rem;\n    }\n\n    .settings select,\n    .settings input[type=\"number\"] {\n      width: 100%;\n      padding: 0.5rem;\n      margin-bottom: 0.625rem;\n      border: 1px solid var(--border-color);\n      border-radius: 0.25rem;\n      box-sizing: border-box;\n    }\n\n    .settings textarea {\n      width: 100%;\n      height: 150px;\n      padding: 0.5rem;\n      border: 1px solid var(--border-color);\n      border-radius: 0.25rem;\n      box-sizing: border-box;\n      margin-bottom: 0.625rem;\n    }\n\n    .checkbox-group {\n      display: flex;\n      align-items: center;\n    }\n\n    .checkbox-group input[type=\"checkbox\"] {\n      margin-left: auto;\n    }\n\n    .main-panel {\n      display: flex;\n      flex-direction: column;\n      width: calc(100vw - 360px - 2.5rem);\n      background-color: var(--bg-primary);\n      box-shadow: 0 0 0.3rem var(--shadow-color);\n      border-radius: 0.3rem;\n    }\n\n    .chat-header {\n      display: flex;\n      flex-direction: row;\n      padding: 1.25rem;\n      border-bottom: 1px solid var(--border-color);\n    }\n\n    .chat-header select {\n      width: 100%;\n      outline: none;\n      font-size: 1.25rem;\n      border: none;\n    }\n\n    .show-sidebar-btn {\n      display: none;\n      width: 1.5rem;\n      height: 1.5rem;\n    }\n\n    .chat-header .toolbar {\n      margin-left: auto;\n    }\n\n    .chat-body {\n      display: flex;\n      flex-direction: column;\n      padding: 0.5rem;\n      flex-grow: 1;\n      overflow-x: hidden;\n      overflow-y: auto;\n    }\n\n    .chat-message {\n      display: flex;\n      padding: 0.7rem;\n      margin-bottom: 0.7rem;\n    }\n\n    .chat-avatar svg {\n      width: 1.25rem;\n      height: 1.25rem;\n      border-radius: 50%;\n    }\n\n    .chat-message-content {\n      position: relative;\n      display: flex;\n      flex-direction: column;\n      width: calc(100% - 1rem);\n      margin-top: -2px;\n      padding-left: 0.625rem;\n      flex-grow: 1;\n    }\n\n    .chat-message-content .error {\n      color: red;\n      background: none;\n      padding: 0;\n    }\n\n    .chat-message-content .message-text {\n      white-space: pre-wrap;\n      padding-top: 0.2rem;\n    }\n\n    .message-image-bar {\n      display: flex;\n      flex-direction: row;\n      overflow-x: auto;\n    }\n\n    .message-image {\n      margin: 0.25rem;\n    }\n\n    .message-image img {\n      width: 10rem;\n      height: 10rem;\n      object-fit: cover;\n    }\n\n    .markdown-body {\n      display: flex;\n      width: 100%;\n      padding: 0;\n      flex-direction: column;\n      background-color: var(--bg-primary);\n    }\n\n    .markdown-body:first-child {\n      margin-top: 0;\n      padding-top: 0;\n    }\n\n    .markdown-body pre {\n      overflow-x: auto;\n      word-wrap: break-word;\n    }\n\n    .code-block {\n      position: relative;\n      width: 100%;\n    }\n\n    .message-toolbox {\n      display: flex;\n      position: absolute;\n      bottom: -1.4rem;\n    }\n\n    .copy-message-btn,\n    .regenerate-message-btn,\n    .tts-message-btn {\n      top: 0.7rem;\n      right: 0.7rem;\n      cursor: pointer;\n      font-size: 0.9rem;\n      padding-right: 4px;\n    }\n\n    .copy-message-btn svg,\n    .regenerate-message-btn svg,\n    .tts-message-btn svg {\n      width: 1rem;\n      height: 1rem;\n    }\n\n    .copy-code-btn {\n      position: absolute;\n      top: 0.7rem;\n      right: 0.7rem;\n      cursor: pointer;\n      font-size: 0.9rem;\n    }\n\n    .copy-code-btn svg {\n      width: 1rem;\n      height: 1rem;\n    }\n\n    .scroll-to-bottom-btn {\n      position: absolute;\n      text-align: center;\n      cursor: pointer;\n      width: 1.5rem;\n      height: 1.5rem;\n      right: calc(50vw - 180px);\n      bottom: 140px;\n      border-radius: 0.75rem;\n      background-color: var(--bg-primary);\n    }\n\n    .scroll-to-bottom-btn svg {\n      width: 1.5rem;\n      height: 1.5rem;\n      border-radius: 50%;\n    }\n\n    .input-panel {\n      position: relative;\n      border-top: 1px solid var(--border-color);\n    }\n\n    .input-panel-inner {\n      margin: 1rem;\n      padding: 0.5rem;\n      border: 1px solid var(--border-color);\n      border-radius: 1rem;\n    }\n\n    .input-panel-inner textarea {\n      width: 100%;\n      font-size: 1rem;\n      padding: 0.4rem;\n      box-sizing: border-box;\n      border: none;\n      outline: none;\n      resize: none;\n      max-height: 500px;\n      overflow-x: hidden;\n      overflow-y: auto;\n    }\n\n    .input-toolbox {\n      position: absolute;\n      display: flex;\n      right: 1.875rem;\n      font-size: 1rem;\n      bottom: 1.875rem;\n      cursor: pointer;\n    }\n\n    .input-toolbox svg {\n      width: 1.875rem;\n      height: 1.875rem;\n      fill: var(--fg-default);\n    }\n\n    .image-btn {\n      position: relative;\n      display: inline-block;\n      margin-right: 0.5rem;\n    }\n\n    .image-btn input[type=\"file\"] {\n      position: absolute;\n      top: 0;\n      left: 0;\n      width: 100%;\n      height: 100%;\n      opacity: 0;\n      cursor: pointer;\n    }\n\n    .input-image-bar {\n      display: flex;\n      flex-direction: row;\n      width: 100%;\n      overflow-x: auto;\n    }\n\n    .input-image-item {\n      display: flex;\n      margin: 0.25rem;\n      width: 5rem;\n      position: relative;\n    }\n\n    .input-image-item img {\n      width: 5rem;\n      height: 5rem;\n      object-fit: cover;\n    }\n\n    .image-remove-btn {\n      font-size: 1rem;\n      margin-left: -0.8rem;\n      cursor: pointer;\n    }\n\n    .image-remove-btn {\n      width: 1rem;\n      height: 1rem;\n    }\n\n    .input-btn.disabled {\n      opacity: 0.3;\n    }\n\n    .session-list {\n      padding-top: 0.4rem;\n      max-height: 80vh;\n      font-size: 0.8rem;\n      overflow-y: auto;\n      overflow-x: hidden;\n    }\n\n    .session-item {\n      padding: 5px;\n      border-bottom: 1px solid var(--border-color);\n      cursor: pointer;\n    }\n\n    .session-item:hover {\n      background-color: var(--bg-hover);\n    }\n\n    .session-title {\n      white-space: nowrap;\n      overflow: hidden;\n      text-overflow: ellipsis;\n    }\n\n    .modal {\n      position: fixed;\n      top: 0;\n      left: 0;\n      width: 100%;\n      height: 100%;\n      background-color: var(--bg-cover);\n      z-index: 1000;\n      display: flex;\n      align-items: flex-start;\n      justify-content: center;\n      padding-top: 50px;\n    }\n\n    .modal-content {\n      position: relative;\n      padding: 0.8rem;\n      border-radius: 8px;\n      max-width: 1000px;\n      width: calc(100% - 100px);\n      background-color: var(--bg-primary);\n      box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);\n    }\n\n    .modal-header {\n      display: flex;\n      flex-direction: row;\n      align-items: center;\n    }\n\n    .modal-header .title {\n      font-weight: 500;\n      font-size: 1.5rem;\n    }\n\n    .modal-header .close-btn {\n      margin-left: auto;\n      color: var(--fg-default);\n      background: none;\n      border: none;\n      font-size: 24px;\n      cursor: pointer;\n    }\n\n    .spinner {\n      width: 1.1rem;\n      height: 1.1rem;\n      margin-top: 3px;\n      border: 2px solid var(--fg-default);\n      border-bottom-color: transparent;\n      border-radius: 50%;\n      display: inline-block;\n      animation: spinner-rotation 1s linear infinite;\n    }\n\n    .toast {\n      display: none;\n      position: fixed;\n      bottom: 1rem;\n      left: 1rem;\n      text-align: center;\n      background-color: var(--bg-toast);\n      color: var(--bg-primary);\n      padding: 0.5rem;\n      border-radius: 0.3rem;\n      z-index: 9999;\n    }\n\n    @keyframes spinner-rotation {\n      0% {\n        transform: rotate(0deg);\n      }\n\n      100% {\n        transform: rotate(360deg);\n      }\n    }\n\n    @media (prefers-color-scheme: dark) {\n      :root {\n        --fg-primary: #1652f1;\n        --fg-default: white;\n        --bg-primary: black;\n        --bg-default: #121212;\n        --bg-toast: rgba(255, 255, 255, 0.7);\n        --bg-cover: rgba(255, 255, 255, 0.5);\n        --bg-hover: #1f1f1f;\n        --border-color: #3c3c3c;\n        --shadow-color: rgba(255, 255, 255, 0.1);\n      }\n    }\n\n    @media screen and (max-width: 768px) {\n      body {\n        height: calc(100vh - 56px);\n        height: 100dvh;\n      }\n\n      .container {\n        padding: 3px;\n      }\n\n      .sidebar {\n        display: none;\n        width: 100%;\n        height: 100%;\n        margin-right: 0;\n      }\n\n      .main-panel {\n        width: 100%;\n      }\n\n      .chat-header {\n        padding: 0.6rem;\n      }\n\n      .chat-header select {\n        font-size: 1rem;\n      }\n\n      .chat-body {\n        padding: 0.6rem;\n      }\n\n      .input-panel-inner {\n        margin: 0.5rem;\n      }\n\n      .scroll-to-bottom-btn {\n        right: 50%;\n      }\n\n      .hide-sidebar-btn {\n        display: block;\n      }\n\n      .show-sidebar-btn {\n        display: block;\n      }\n    }\n  </style>\n</head>\n\n<body>\n  <div class=\"container\" x-data=\"app\">\n    <div class=\"sidebar\" x-ref=\"sidebar\">\n      <div class=\"sidebar-header\">\n        <div class=\"sidebar-left\">\n          <div class=\"title\">AIChat</div>\n          <div class=\"subtitle\">All-in-one AI-Powered Chat</div>\n        </div>\n        <div class=\"sidebar-right\">\n          <div class=\"sidebar-btn new-chat-btn\" title=\"New Chat (Ctrl/Cmd+Shift+O)\" @click=\"handleNewChat\">\n            <svg fill=\"currentColor\" viewBox=\"0 0 16 16\">\n              <path d=\"M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14m0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16\" />\n              <path\n                d=\"M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4\" />\n            </svg>\n          </div>\n          <div class=\"sidebar-btn list-sessions-btn\" title=\"List Sessions (Ctrl/Cmd+Shift+L)\"\n            @click=\"showModal = 'list-sessions'\">\n            <svg fill=\"currentColor\" viewBox=\"0 0 16 16\">\n              <path fill-rule=\"evenodd\"\n                d=\"M2 2.5a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5V3a.5.5 0 0 0-.5-.5zM3 3H2v1h1z\" />\n              <path\n                d=\"M5 3.5a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5M5.5 7a.5.5 0 0 0 0 1h9a.5.5 0 0 0 0-1zm0 4a.5.5 0 0 0 0 1h9a.5.5 0 0 0 0-1z\" />\n              <path fill-rule=\"evenodd\"\n                d=\"M1.5 7a.5.5 0 0 1 .5-.5h1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5H2a.5.5 0 0 1-.5-.5zM2 7h1v1H2zm0 3.5a.5.5 0 0 0-.5.5v1a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5zm1 .5H2v1h1z\" />\n            </svg>\n          </div>\n          <div class=\"sidebar-btn hide-sidebar-btn\" @click=\"handleHideSidebarBtnClick\">\n            <svg fill=\"currentColor\" viewBox=\"0 0 16 16\">\n              <path\n                d=\"M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708\" />\n            </svg>\n          </div>\n        </div>\n      </div>\n      <div class=\"settings\">\n        <div class=\"control\">\n          <label for=\"role\">RAG</label>\n          <select id=\"role\" x-model=\"settings.rag\" :disabled=\"sessionMode\">\n            <template x-for=\"rag in rags\">\n              <option :value=\"rag\" :selected=\"rag == settings.rag\" x-text=\"rag\"></option>\n            </template>\n          </select>\n        </div>\n\n        <div class=\"control\">\n          <label for=\"role\">Role</label>\n          <select id=\"role\" x-model=\"settings.role\" :disabled=\"sessionMode\">\n            <template x-for=\"role in roles\">\n              <option :value=\"role.name\" :selected=\"role.name == settings.role\" x-text=\"role.name\"></option>\n            </template>\n          </select>\n        </div>\n\n        <div class=\"control\">\n          <label for=\"prompt\">System Prompt</label>\n          <textarea id=\"prompt\" x-model=\"settings.prompt\" :disabled=\"sessionMode\"></textarea>\n        </div>\n\n        <div class=\"control\">\n          <label for=\"max_output_tokens\"\n            x-text=\"'Max Output Tokens' + (modelData.max_output_token ? ' [1, ' + modelData.max_output_token + ']' : '')\">Max\n            Output Tokens</label>\n          <input type=\"number\" id=\"max_output_tokens\" x-model.number=\"settings.max_output_tokens\">\n        </div>\n\n        <div class=\"control\">\n          <label for=\"temperature\">Temperature</label>\n          <input type=\"number\" id=\"temperature\" x-model.number=\"settings.temperature\">\n        </div>\n\n        <div class=\"control\">\n          <label for=\"top_p\">Top P</label>\n          <input type=\"number\" id=\"top_p\" x-model.number=\"settings.top_p\">\n        </div>\n\n      </div>\n    </div>\n    <div class=\"main-panel\" x-ref=\"main-panel\">\n      <div class=\"chat-header\">\n        <select id=\"model\" x-model=\"settings.model\">\n          <template x-for=\"model in models\" :key=\"model.id\">\n            <option :value=\"model.id\" :selected=\"model.id == settings.model\" x-text=\"model.id\"></option>\n          </template>\n        </select>\n        <div class=\"toolbar\">\n          <div class=\"show-sidebar-btn\" @click=\"handleShowSidebarBtnClick\">\n            <svg fill=\"currentColor\" viewBox=\"0 0 16 16\">\n              <path\n                d=\"M3 9.5a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3m5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3m5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3\" />\n            </svg>\n          </div>\n        </div>\n      </div>\n      <div class=\"chat-body\" x-ref=\"chat-body\" @scroll=\"handleScrollChatBody\">\n        <template x-for=\"(message, index) in messages\" :key=\"message.id\">\n          <div class=\"chat-message\" @mouseover=\"hoveredMessageIndex = index\" @mouseleave=\"messageHoveredIndex = null\">\n            <div class=\"chat-avatar\" :class=\"message.role == 'user' ? 'chat-avatar user' : 'chat-avatar assistant'\">\n              <template x-if=\"message.role == 'user'\">\n                <svg fill=\"currentColor\" viewBox=\"0 0 16 16\">\n                  <path d=\"M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0\" />\n                  <path fill-rule=\"evenodd\"\n                    d=\"M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8m8-7a7 7 0 0 0-5.468 11.37C3.242 11.226 4.805 10 8 10s4.757 1.225 5.468 2.37A7 7 0 0 0 8 1\" />\n                </svg>\n              </template>\n              <template x-if=\"message.role == 'assistant'\">\n                <svg fill=\"currentColor\" viewBox=\"0 0 16 16\">\n                  <path\n                    d=\"M6 12.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 0 1h-3a.5.5 0 0 1-.5-.5M3 8.062C3 6.76 4.235 5.765 5.53 5.886a26.6 26.6 0 0 0 4.94 0C11.765 5.765 13 6.76 13 8.062v1.157a.93.93 0 0 1-.765.935c-.845.147-2.34.346-4.235.346s-3.39-.2-4.235-.346A.93.93 0 0 1 3 9.219zm4.542-.827a.25.25 0 0 0-.217.068l-.92.9a25 25 0 0 1-1.871-.183.25.25 0 0 0-.068.495c.55.076 1.232.149 2.02.193a.25.25 0 0 0 .189-.071l.754-.736.847 1.71a.25.25 0 0 0 .404.062l.932-.97a25 25 0 0 0 1.922-.188.25.25 0 0 0-.068-.495c-.538.074-1.207.145-1.98.189a.25.25 0 0 0-.166.076l-.754.785-.842-1.7a.25.25 0 0 0-.182-.135\" />\n                  <path\n                    d=\"M8.5 1.866a1 1 0 1 0-1 0V3h-2A4.5 4.5 0 0 0 1 7.5V8a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1v1a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-1a1 1 0 0 0 1-1V9a1 1 0 0 0-1-1v-.5A4.5 4.5 0 0 0 10.5 3h-2zM14 7.5V13a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V7.5A3.5 3.5 0 0 1 5.5 4h5A3.5 3.5 0 0 1 14 7.5\" />\n                </svg>\n              </template>\n            </div>\n            <div class=\"chat-message-content\">\n              <!-- message -->\n              <template x-if=\"message.role == 'assistant' && message.html\">\n                <div class=\"markdown-body\" x-html=\"message.html\"></div>\n              </template>\n              <template x-if=\"message.role == 'assistant' && message.state == 'loading'\">\n                <div class=\"spinner\"></div>\n              </template>\n              <template x-if=\"message.role == 'user' && Array.isArray(message.content)\">\n                <div class=\"message-text-images\">\n                  <template x-if=\"message.content[0].text\">\n                    <div class=\"message-text\" x-text=\"message.content[0].text\"></div>\n                  </template>\n                  <div class=\"message-image-bar\">\n                    <template x-for=\"part in message.content\">\n                      <template x-if=\"part.type == 'image_url'\">\n                        <div class=\"message-image\">\n                          <img :src=\"part.image_url.url\" alt=\"Image Message Part\">\n                        </div>\n                      </template>\n                    </template>\n                  </div>\n                </div>\n              </template>\n              <template\n                x-if=\"message.role == 'user' && Object.prototype.toString.call(message.content) == '[object String]'\">\n                <div class=\"message-text\" x-text=\"message.content\"></div>\n              </template>\n              <!-- toolbox -->\n              <template x-if=\"index == hoveredMessageIndex\">\n                <div class=\"message-toolbox\">\n                  <div class=\"copy-message-btn\" @click=\"handleCopyMessage(message.content)\" title=\" Copy\">\n                    <svg fill=\"currentColor\" viewBox=\"0 0 16 16\">\n                      <path fill-rule=\"evenodd\"\n                        d=\"M4 2a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2zm2-1a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1zM2 5a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1v-1h1v1a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h1v1z\" />\n                    </svg>\n                  </div>\n                  <template\n                    x-if=\"index == messages.length - 1 && (message.state == 'succeed' || message.state == 'failed')\">\n                    <div class=\"regenerate-message-btn\" @click=\"handleRegenerateMessage\" title=\"Regenerate\">\n                      <svg fill=\"currentColor\" viewBox=\"0 0 16 16\">\n                        <path fill-rule=\"evenodd\" d=\"M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2z\" />\n                        <path\n                          d=\"M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466\" />\n                      </svg>\n                    </div>\n                  </template>\n                  <template x-if=\"message.state == 'succeed' && !!window.speechSynthesis\">\n                    <div class=\"tts-message-btn\" @click=\"handleTTSMessage(message.content)\" title=\"Text to speech\">\n                      <svg fill=\"currentColor\" viewBox=\"0 0 16 16\">\n                        <path\n                          d=\"M11.536 14.01A8.47 8.47 0 0 0 14.026 8a8.47 8.47 0 0 0-2.49-6.01l-.708.707A7.48 7.48 0 0 1 13.025 8c0 2.071-.84 3.946-2.197 5.303z\" />\n                        <path\n                          d=\"M10.121 12.596A6.48 6.48 0 0 0 12.025 8a6.48 6.48 0 0 0-1.904-4.596l-.707.707A5.48 5.48 0 0 1 11.025 8a5.48 5.48 0 0 1-1.61 3.89z\" />\n                        <path\n                          d=\"M10.025 8a4.5 4.5 0 0 1-1.318 3.182L8 10.475A3.5 3.5 0 0 0 9.025 8c0-.966-.392-1.841-1.025-2.475l.707-.707A4.5 4.5 0 0 1 10.025 8M7 4a.5.5 0 0 0-.812-.39L3.825 5.5H1.5A.5.5 0 0 0 1 6v4a.5.5 0 0 0 .5.5h2.325l2.363 1.89A.5.5 0 0 0 7 12zM4.312 6.39 6 5.04v5.92L4.312 9.61A.5.5 0 0 0 4 9.5H2v-3h2a.5.5 0 0 0 .312-.11\" />\n                      </svg>\n                    </div>\n                  </template>\n                </div>\n              </template>\n            </div>\n          </div>\n        </template>\n      </div>\n      <div class=\"scroll-to-bottom-btn\" x-cloak x-show=\"isShowScrollToBottomBtn\" @click=\"handleScrollToBottom\">\n        <svg fill=\"currentColor\" viewBox=\"0 0 16 16\">\n          <path fill-rule=\"evenodd\"\n            d=\"M1 8a7 7 0 1 0 14 0A7 7 0 0 0 1 8m15 0A8 8 0 1 1 0 8a8 8 0 0 1 16 0M8.5 4.5a.5.5 0 0 0-1 0v5.793L5.354 8.146a.5.5 0 1 0-.708.708l3 3a.5.5 0 0 0 .708 0l3-3a.5.5 0 0 0-.708-.708L8.5 10.293z\" />\n        </svg>\n      </div>\n      <div class=\"input-panel\">\n        <div class=\"input-panel-inner\">\n          <textarea id=\"chat-input\" x-model=\"input\" x-ref=\"input\" @keydown.enter=\"handleEnterKeyDown\"\n            placeholder=\"Ask Anything\" autofocus></textarea>\n          <div class=\"input-image-bar\" x-show=\"images.length > 0\">\n            <template x-for=\"(image, index) in images\">\n              <div class=\"input-image-item\">\n                <img :src=\"image\" alt=\"Preview image\">\n                <div class=\"image-remove-btn\" @click=\"images.splice(index, 1);\">\n                  <svg fill=\"currentColor\" viewBox=\"0 0 16 16\">\n                    <path\n                      d=\"M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5m2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5m3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0z\" />\n                    <path\n                      d=\"M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4zM2.5 3h11V2h-11z\" />\n                  </svg>\n                </div>\n              </div>\n            </template>\n          </div>\n          <template x-if=\"asking\">\n            <div class=\"input-toolbox\">\n              <div class=\"input-btn\" @click=\"handleCancelAsk\">\n                <svg fill=\"currentColor\" viewBox=\"0 0 16 16\">\n                  <path d=\"M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14m0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16\" />\n                  <path\n                    d=\"M5 6.5A1.5 1.5 0 0 1 6.5 5h3A1.5 1.5 0 0 1 11 6.5v3A1.5 1.5 0 0 1 9.5 11h-3A1.5 1.5 0 0 1 5 9.5z\" />\n                </svg>\n              </div>\n            </div>\n          </template>\n          <template x-if=\"!asking\">\n            <div class=\"input-toolbox\">\n              <div class=\"image-btn\" x-show=\"modelData.supports_vision\">\n                <input type=\"file\" multiple accept=\".jpg,.jpeg,.png,.webp\" @change=\"handleImageUpload\">\n                <svg fill=\"currentColor\" viewBox=\"0 0 16 16\">\n                  <path d=\"M6.002 5.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0\" />\n                  <path\n                    d=\"M2.002 1a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V3a2 2 0 0 0-2-2zm12 1a1 1 0 0 1 1 1v6.5l-3.777-1.947a.5.5 0 0 0-.577.093l-3.71 3.71-2.66-1.772a.5.5 0 0 0-.63.062L1.002 12V3a1 1 0 0 1 1-1z\" />\n                </svg>\n              </div>\n              <div class=\"input-btn\" :class=\"(input.trim() || images.length > 0) ? 'input-btn' : 'input-btn disabled'\"\n                @click=\"handleAsk\">\n                <svg fill=\"currentColor\" viewBox=\"0 0 16 16\">\n                  <path\n                    d=\"M2 16a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2zm6.5-4.5V5.707l2.146 2.147a.5.5 0 0 0 .708-.708l-3-3a.5.5 0 0 0-.708 0l-3 3a.5.5 0 1 0 .708.708L7.5 5.707V11.5a.5.5 0 0 0 1 0\" />\n                </svg>\n              </div>\n            </div>\n          </template>\n        </div>\n      </div>\n    </div>\n    <div class=\"modal\" x-cloak x-show=\"showModal == 'list-sessions'\"\n      @click=\"if ($event.target == $el) { showModal = ''}\">\n      <div class=\"modal-content\">\n        <div class=\"modal-header\">\n          <div class=\"title\">Sessions</div>\n          <button class=\"close-btn\" @click=\"showModal = ''\">&times;</button>\n        </div>\n        <div class=\"session-list\">\n          <template x-for=\"session in sessions\" :key=\"session.id\">\n            <div class=\"session-item\" @click=\"handleSelectSession(session.id)\">\n              <div class=\"session-title\" x-text=\"session.sessionTitle\"></div>\n            </div>\n          </template>\n        </div>\n      </div>\n    </div>\n    <div id=\"toast\" class=\"toast\"></div>\n  </div>\n  <script>\n    const QUERY = parseQueryString();\n    const API_BASE = QUERY.api_base || \"./v1\";\n    const API_KEY = QUERY.api_key || \"\";\n    const CHAT_COMPLETIONS_URL = API_BASE + \"/chat/completions\";\n    const MODELS_API = API_BASE + \"/models\";\n    const ROLES_API = API_BASE + \"/roles\";\n    const RAGS_API = API_BASE + \"/rags\";\n    const SEARCH_RAG_API = API_BASE + \"/rags/search\";\n\n    document.addEventListener(\"alpine:init\", () => {\n      setupMarked();\n      setupApp();\n    });\n\n    function setupApp() {\n      let msgIdx = 0;\n      let defaultSettings = {\n        model: QUERY.model || \"default\",\n        rag: QUERY.rag || \"\",\n        role: QUERY.role || \"\",\n        prompt: \"\",\n        max_output_tokens: parseInt(QUERY.max_output_tokens) || null,\n        temperature: QUERY.temperature ? parseFloat(QUERY.temperature) : null,\n        top_p: QUERY.top_p ? parseFloat(QUERY.top_p) : null,\n      };\n\n      Alpine.data(\"app\", () => ({\n        models: [],\n        rags: [\"\"],\n        roles: [{ name: \"\", prompt: \"\" }],\n        settings: defaultSettings,\n        modelData: {},\n        messages: [],\n        input: \"\",\n        images: [],\n        asking: false,\n        askAbortController: null,\n        hoveredMessageIndex: null,\n        shouldScrollChatBodyToBottom: true,\n        isShowScrollToBottomBtn: false,\n        showModal: \"\",\n        sessionMode: false,\n        sessionTitle: \"\",\n        selectSessionId: null,\n        sessions: [],\n\n        async init() {\n          await Promise.all([\n            fetchJSON(MODELS_API).then(models => {\n              this.models = models.filter(v => !v.type || v.type === \"chat\");\n            }).catch(err => {\n              toast(\"No model available\");\n              console.error(\"Failed to load models\", err);\n            }),\n            fetchJSON(RAGS_API).then(rags => {\n              this.rags.push(...rags);\n            }).catch(() => { }),\n            fetchJSON(ROLES_API).then(roles => {\n              this.roles.push(...roles.filter(v => !!v.prompt));\n            }).catch(() => { }),\n          ])\n          this.$refs.input.addEventListener(\"paste\", (e) => this.handlePaste(e));\n          this.$watch(\"input\", () => this.autosizeInput(this.$refs.input));\n          this.$watch(\"settings\", () => this.updateUrl());\n          this.$watch(\"settings.model\", () => this.handleModelChange());\n          if (this.models.find(model => model.id === this.settings.model)) {\n            this.handleModelChange();\n          } else {\n            this.settings.model = \"default\";\n          }\n          if (!this.rags.find(rag => rag === this.settings.rag)) {\n            this.settings.rag = \"\";\n          }\n          this.$watch(\"settings.role\", () => this.handleRoleChange())\n          if (this.roles.find(role => role.name === this.settings.role)) {\n            this.handleRoleChange();\n          } else {\n            this.settings.role = \"\";\n          }\n          document.addEventListener(\"keydown\", (event) => this.handleKeyDown(event))\n        },\n\n        handleAsk() {\n          const isEmptyInput = this.input.trim() === \"\";\n          const isEmptyImage = this.images.length === 0;\n          if (this.asking || (isEmptyImage && isEmptyInput)) {\n            return;\n          }\n          if (this.messages.length === 0) {\n            let sessionTitle = \"\"\n            if (this.images.length > 0) {\n              sessionTitle = `🖼️x${this.images.length} `\n            }\n            if (this.input) {\n              sessionTitle += this.input.trim().replace(/\\n/g, \"↵\").slice(0, 200);\n            }\n            this.sessionTitle = sessionTitle;\n          }\n          if (isEmptyImage) {\n            this.messages.push({\n              id: msgIdx++,\n              role: \"user\",\n              content: this.input,\n            });\n          } else {\n            const parts = [];\n            if (!isEmptyInput) {\n              parts.push({ type: \"text\", text: this.input });\n            }\n            for (const image of this.images) {\n              parts.push({ type: \"image_url\", image_url: { url: image } });\n            }\n            this.messages.push({\n              id: msgIdx++,\n              role: \"user\",\n              content: parts,\n            })\n          }\n          this.messages.push({\n            id: msgIdx++,\n            role: \"assistant\",\n            content: \"\",\n            state: \"loading\", // streaming, succeed, failed\n            error: \"\",\n            html: \"\",\n          });\n          this.input = \"\";\n          this.asking = true;\n          this.images = [];\n          this.ask();\n        },\n\n        handleRegenerateMessage() {\n          const lastIndex = this.messages.length - 1;\n          if (lastIndex !== this.hoveredMessageIndex) {\n            return\n          }\n          let lastMessage = this.messages[lastIndex];\n          lastMessage.content = \"\";\n          lastMessage.state = \"loading\";\n          lastMessage.error = \"\";\n          lastMessage.html = \"\";\n          this.asking = true;\n          this.ask();\n        },\n\n        /**\n         * @param {string} messageToUtter\n         */\n        handleTTSMessage(messageToUtter) {\n          if (!!window.speechSynthesis) {\n            if (window.speechSynthesis.speaking || window.speechSynthesis.pending) {\n              window.speechSynthesis.cancel();\n            } else {\n              let utterance = new SpeechSynthesisUtterance(messageToUtter);\n              window.speechSynthesis.speak(utterance);\n            }\n          }\n        },\n\n        handleCancelAsk() {\n          this.askAbortController?.abort();\n        },\n\n        handleModelChange() {\n          this.modelData = retrieveModel(this.models, this.settings.model);\n        },\n\n        handleRoleChange() {\n          if (this.settings.prompt && !this.settings.role) {\n            return;\n          }\n          this.settings.prompt = this.roles.find(role => role.name === this.settings.role).prompt;\n        },\n\n        handleScrollChatBody(event) {\n          const $chatBody = event.target;\n          const { scrollTop, clientHeight, scrollHeight, _prevScrollTop = 0 } = $chatBody;\n          if (scrollTop + clientHeight > scrollHeight - 5) {\n            this.isShowScrollToBottomBtn = false;\n            this.shouldScrollChatBodyToBottom = true;\n          }\n          if (scrollHeight > clientHeight && _prevScrollTop > 1 && _prevScrollTop > scrollTop + 1) {\n            this.shouldScrollChatBodyToBottom = false;\n            this.isShowScrollToBottomBtn = true;\n          }\n          $chatBody._prevScrollTop = scrollTop;\n        },\n\n        handleScrollToBottom() {\n          const $chatBody = this.$refs[\"chat-body\"];\n          $chatBody.scrollTop = $chatBody.scrollHeight;\n          this.isShowScrollToBottomBtn = false;\n          this.shouldScrollChatBodyToBottom = true;\n        },\n\n        handleShowSidebarBtnClick() {\n          this.$refs.sidebar.style.display = 'block';\n          this.$refs[\"main-panel\"]._display = this.$refs[\"main-panel\"].style.display;\n          this.$refs[\"main-panel\"].style.display = \"none\";\n        },\n\n        handleHideSidebarBtnClick() {\n          this.$refs.sidebar.style.display = 'none';\n          this.$refs[\"main-panel\"].style.display = this.$refs[\"main-panel\"]._display;\n        },\n\n        handleEnterKeyDown(event) {\n          if (event.shiftKey) {\n            return;\n          }\n          event.preventDefault();\n          this.handleAsk();\n        },\n\n        handleCopyCode(event) {\n          const $btn = event.target;\n          const $code = $btn.closest('.code-block').querySelector(\"code\");\n          if ($code) {\n            const range = document.createRange();\n            range.selectNodeContents($code);\n            window.getSelection().removeAllRanges();\n            window.getSelection().addRange(range);\n            document.execCommand('copy');\n            window.getSelection().removeAllRanges();\n            toast(\"Copied Code\");\n          }\n        },\n\n        handleCopyMessage(content) {\n          if (Array.isArray(content)) {\n            content = content.map(v => v.text || \"\").join(\"\");\n          }\n\n          const $tempTextArea = document.createElement(\"textarea\");\n          $tempTextArea.value = content;\n          document.body.appendChild($tempTextArea);\n          $tempTextArea.select();\n          $tempTextArea.setSelectionRange(0, 99999); // For mobile devices\n          document.execCommand(\"copy\");\n          document.body.removeChild($tempTextArea);\n          toast(\"Copied Message\")\n        },\n\n        async handleImageUpload(event) {\n          const files = event.target.files;\n          if (!files || files.length === 0) {\n            return;\n          }\n          const urls = await Promise.all(Array.from(files).map(file => convertImageToDataURL(file)));\n          this.images.push(...urls);\n          event.target.value = \"\";\n        },\n\n        async handlePaste(event) {\n          const files = Array.from(event.clipboardData.items).filter(v => v.type.startsWith('image/')).map(v => v.getAsFile());\n          const urls = await Promise.all(files.map(file => convertImageToDataURL(file)));\n          this.images.push(...urls);\n        },\n\n        handleKeyDown(event) {\n          const isMac = navigator.platform.toUpperCase().indexOf('MAC') > -1;\n          const controlKey = isMac ? event.metaKey : event.ctrlKey;\n          if (controlKey && event.shiftKey && event.key.toLowerCase() === 'o') {\n            event.preventDefault();\n            this.handleNewChat();\n          } else if (controlKey && event.shiftKey && event.key.toLowerCase() === 'l') {\n            event.preventDefault();\n            this.showModal = 'list-sessions'\n          } else if (event.shiftKey && event.key === \"Escape\") {\n            event.preventDefault();\n            this.focusInput();\n          } else if (this.showModal && event.key === \"Escape\") {\n            event.preventDefault();\n            this.showModal = \"\";\n          }\n        },\n\n        handleNewChat() {\n          if (this.asking) {\n            this.askAbortController?.abort();\n          }\n          if (this.sessionTitle) {\n            const lastMessage = this.messages[this.messages.length - 1];\n            if (lastMessage.state === \"loading\") {\n              lastMessage.state = \"failed\";\n              lastMessage.error = \"Error: Aborted\";\n              lastMessage.html = renderMarkdown(lastMessage.content, lastMessage.error);\n            }\n            const sessionData = JSON.parse(JSON.stringify({\n              settings: this.settings,\n              messages: this.messages,\n              sessionMode: this.sessionMode,\n              sessionTitle: this.sessionTitle,\n            }));\n            let session = this.sessions.find(v => v.id === this.selectSessionId);\n            if (session) {\n              Object.assign(session, sessionData);\n            } else {\n              this.sessions.unshift({\n                id: randomUUID(),\n                createdAt: Date.now(),\n                ...sessionData,\n              });\n            }\n          }\n          this.messages = [];\n          this.asking = false;\n          this.askAbortController = null;\n          this.hoveredMessageIndex = null;\n          this.shouldScrollChatBodyToBottom = true;\n          this.isShowScrollToBottomBtn = false;\n          this.showModal = \"\";\n          this.sessionMode = false;\n          this.sessionTitle = \"\";\n          this.selectSessionId = null;\n\n          this.focusInput();\n        },\n\n        handleSelectSession(id) {\n          const session = this.sessions.find(v => v.id === id);\n          if (!session || id === this.selectSessionId) {\n            this.showModal = \"\";\n            this.focusInput();\n            return;\n          }\n          this.handleNewChat();\n          this.settings = session.settings;\n          this.messages = session.messages;\n          this.sessionMode = session.sessionMode;\n          this.sessionTitle = session.sessionTitle;\n          this.selectSessionId = session.id;\n        },\n\n        updateUrl() {\n          const newUrl = new URL(location.href);\n          [\"model\", \"rag\", \"role\", \"max_output_tokens\", \"temperature\", \"top_p\"].forEach(key => {\n            if (this.settings[key] || typeof this.settings[key] === \"number\") {\n              newUrl.searchParams.set(key, this.settings[key]);\n            } else {\n              newUrl.searchParams.delete(key);\n            }\n          });\n          history.replaceState(null, '', newUrl.toString());\n        },\n\n        autoScrollChatBodyToBottom() {\n          if (this.shouldScrollChatBodyToBottom) {\n            let $chatBody = this.$refs[\"chat-body\"];\n            if (!$chatBody) {\n              $chatBody = document.querySelector('[x-ref=\"chat-body\"]')\n            }\n            $chatBody.scrollTop = $chatBody.scrollHeight;\n          }\n        },\n\n        autosizeInput($input) {\n          $input.style.height = 'auto';\n          $input.style.height = $input.scrollHeight + 'px';\n        },\n\n        focusInput() {\n          this.$refs?.input?.focus();\n        },\n\n        async ask() {\n          this.askAbortController = new AbortController();\n          this.shouldScrollChatBodyToBottom = true;\n          this.$nextTick(() => {\n            this.autoScrollChatBodyToBottom();\n          });\n          const lastMessage = this.messages[this.messages.length - 1];\n          const body = this.buildBody();\n          let succeed = false;\n          try {\n            if (this.settings.rag) {\n              const message = body.messages[body.messages.length - 1];\n              if (message.role === \"user\" && typeof message.content === \"string\") {\n                message.content = await this.searchRag(this.settings.rag, message.content);\n              }\n            }\n            const stream = await fetchChatCompletions(CHAT_COMPLETIONS_URL, body, this.askAbortController.signal)\n            for await (const chunk of stream) {\n              lastMessage.state = \"streaming\";\n              lastMessage.content += chunk?.choices[0]?.delta?.content || \"\";\n              lastMessage.html = renderMarkdown(lastMessage.content, lastMessage.error);\n              this.$nextTick(() => {\n                this.autoScrollChatBodyToBottom();\n              });\n            }\n            lastMessage.state = \"succeed\";\n            succeed = true;\n          } catch (err) {\n            lastMessage.state = \"failed\";\n            if (this.askAbortController?.signal?.aborted) {\n              lastMessage.error = \"Error: Aborted\";\n            } else {\n              lastMessage.error = err?.message || err;\n            }\n            lastMessage.html = renderMarkdown(lastMessage.content, lastMessage.error);\n          }\n          if (succeed) {\n            this.sessionMode = true;\n          }\n          this.asking = false;\n        },\n\n        async searchRag(name, input) {\n          const res = await fetch(SEARCH_RAG_API, {\n            method: \"POST\",\n            headers: getHeaders(),\n            signal: this.askAbortController.signal,\n            body: JSON.stringify({\n              name,\n              input\n            })\n          });\n          const data = await res.json();\n          return data.data;\n        },\n\n        buildBody() {\n          let messages = [];\n          for ([userMessage, assistantMessage] of chunkArray(this.messages, 2)) {\n            if (assistantMessage.state === \"failed\") {\n              continue;\n            } else if (assistantMessage.state === \"loading\") {\n              messages.push({\n                role: userMessage.role,\n                content: userMessage.content,\n              });\n            } else {\n              messages.push({\n                role: userMessage.role,\n                content: userMessage.content,\n              });\n              messages.push({\n                role: assistantMessage.role,\n                content: assistantMessage.content,\n              });\n            }\n          }\n          const systemPrompt = this.settings.prompt.trim();\n          if (systemPrompt) {\n            if (messages[0]?.content?.indexOf(\"__INPUT__\") > -1) {\n              messages[0].content = systemPrompt.replace(\"__INPUT__\", messages[0].content);\n            } else {\n              const { system, cases } = parseStructurePrompt(systemPrompt);\n              const promptMessages = [];\n              if (system) {\n                promptMessages.push({\n                  role: \"system\",\n                  content: system,\n                });\n              }\n              for (const item of cases) {\n                promptMessages.push({\n                  role: \"user\",\n                  content: item.input,\n                });\n                promptMessages.push({\n                  role: \"assistant\",\n                  content: item.output,\n                });\n              }\n              messages = [...promptMessages, ...messages];\n            }\n          }\n          sanitizeMessages(messages);\n          const body = {\n            model: this.settings.model,\n            messages: messages,\n            stream: true,\n          };\n          [[\"max_output_tokens\", \"max_tokens\"], [\"temperature\"], [\"top_p\"]].forEach(([setting_key, body_key]) => {\n            if (typeof this.settings[setting_key] === \"number\") {\n              body[body_key || setting_key] = this.settings[setting_key];\n            }\n          });\n          const { max_output_token, require_max_tokens } = this.modelData;\n          if (!body[\"max_tokens\"] && require_max_tokens) {\n            body[\"max_tokens\"] = max_output_token;\n          };\n          return body;\n        },\n      }));\n\n    }\n\n    async function fetchJSON(url) {\n      const res = await fetch(url, { headers: getHeaders() });\n      const data = await res.json()\n      return data.data;\n    }\n\n    async function* fetchChatCompletions(url, body, signal) {\n      const stream = body.stream;\n      const response = await fetch(url, {\n        method: \"POST\",\n        signal,\n        headers: getHeaders(),\n        body: JSON.stringify(body),\n      });\n\n      if (!response.ok) {\n        const error = await response.json();\n        throw error?.error || error;\n      }\n\n      if (!stream) {\n        const data = await response.json();\n        return data;\n      }\n      const reader = response.body.getReader();\n      const decoder = new TextDecoder();\n      let done = false;\n      let reamingChunkValue = \"\";\n\n      while (!done) {\n        if (signal?.aborted) {\n          reader.cancel();\n          break;\n        }\n        const { value, done: doneReading } = await reader.read();\n        done = doneReading;\n        const chunkValue = decoder.decode(value);\n        const lines = (reamingChunkValue + chunkValue).split(\"\\n\").filter(line => line.trim().length > 0);\n        reamingChunkValue = \"\";\n\n        for (let i = 0; i < lines.length; i++) {\n          const line = lines[i];\n          const message = line.replace(/^data: /, \"\");\n          if (message === \"[DONE]\") {\n            continue\n          }\n          try {\n            const parsed = JSON.parse(message);\n            yield parsed;\n          } catch {\n            if (i === lines.length - 1) {\n              reamingChunkValue += line;\n              break;\n            }\n          }\n        }\n      }\n    }\n\n    function getHeaders() {\n      const headers = {\n        \"content-type\": \"application/json\",\n      };\n      if (API_KEY) {\n        headers[\"authorization\"] = `Bearer ${API_KEY}`;\n      }\n      return headers\n    }\n\n    function retrieveModel(models, id) {\n      const model = models.find(model => model.id === id);\n      if (!model) return {};\n      const max_output_token = model.max_output_tokens;\n      const supports_vision = !!model.supports_vision;\n      const require_max_tokens = !!model.require_max_tokens;\n      return {\n        id,\n        max_output_token,\n        supports_vision,\n        require_max_tokens,\n      }\n    }\n\n    function toast(text, duration = 2500) {\n      const $toast = document.getElementById(\"toast\");\n      clearTimeout($toast._timer);\n      $toast.textContent = text;\n      $toast.style.display = \"block\";\n      $toast._timer = setTimeout(function () {\n        $toast.style.display = \"none\";\n      }, duration);\n    }\n\n    function parseStructurePrompt(prompt) {\n      let text = prompt;\n      let searchInput = true;\n      let system = null;\n      let parts = [];\n\n      while (text) {\n        const search = searchInput ? \"### INPUT:\" : \"### OUTPUT:\";\n        const index = text.indexOf(search);\n\n        if (index !== -1) {\n          if (system === null) {\n            system = text.slice(0, index);\n          } else {\n            parts.push(text.slice(0, index));\n          }\n          searchInput = !searchInput;\n          text = text.slice(index + search.length);\n        } else {\n          if (text.trim()) {\n            if (system === null) {\n              system = text;\n            } else {\n              parts.push(text);\n            }\n          }\n          break;\n        }\n      }\n\n      const partsLength = parts.length;\n      if (partsLength > 0 && partsLength % 2 === 0) {\n        const cases = parts.reduce((acc, val, idx) => {\n          if (idx % 2 === 0) {\n            acc.push({ input: val.trim() })\n          } else {\n            acc[acc.length - 1].output = val.trim();\n          }\n          return acc;\n        }, []);\n        system = system ? system.trim() : \"\";\n        return { system, cases }\n      }\n\n      return { system: prompt, cases: [] }\n    }\n\n    function sanitizeMessages(messages) {\n      let messagesLen = messages.length;\n      for (let i = 0; i < messagesLen; i++) {\n        const message = messages[i];\n        if (typeof message.content === \"string\" && message.role === \"assistant\" && i !== messagesLen - 1) {\n          message.content = stripThinkTag(message.content);\n        }\n      }\n    }\n\n    function stripThinkTag(text) {\n      return text.replace(/^\\s*<think>([\\s\\S]*?)<\\/think>(\\s*|$)/g, '')\n    }\n\n    function convertImageToDataURL(imageFile) {\n      return new Promise((resolve, reject) => {\n        if (!imageFile) {\n          reject(new Error(\"Please select an image file.\"));\n          return;\n        }\n\n        const reader = new FileReader();\n        reader.readAsDataURL(imageFile);\n        reader.onload = (event) => resolve(event.target.result);\n        reader.onerror = (error) => reject(error);\n      });\n    }\n\n    function setupMarked() {\n      const renderer = {\n        code({ text, lang }) {\n          const validLang = !!(lang && hljs.getLanguage(lang));\n          const highlighted = validLang\n            ? hljs.highlight(text, { language: lang }).value\n            : escapeForHTML(text);\n\n          return `<div class=\"code-block\">\n        <pre><code class=\"hljs ${lang}\">${highlighted}</code></pre>\n  <div class=\"copy-code-btn\" @click=\"handleCopyCode\" title=\"Copy code\">\n    <svg fill=\"currentColor\" viewBox=\"0 0 16 16\">\n      <path fill-rule=\"evenodd\" d=\"M4 2a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2zm2-1a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1zM2 5a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1v-1h1v1a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h1v1z\"/>\n    </svg>\n  </div>\n</div>`;\n        }\n      };\n      const thinkExtension = {\n        name: 'think',\n        level: 'block',\n        start(src) {\n          const match = /^(\\s*)<think>/.exec(src);\n          if (match) {\n            return match[1].length\n          } else {\n            return -1;\n          }\n        },\n        tokenizer(src, tokens) {\n          const rule = /^\\s*<think>([\\s\\S]*?)(<\\/think>|$)/;\n          const match = rule.exec(src);\n          if (match) {\n            return {\n              type: 'think',\n              raw: match[0],\n              text: match[1].trim(),\n            };\n          }\n        },\n        renderer(token) {\n          const text = '<p>' + token.text.trim().replace(/\\n+/g, '</p><p>') + '</p>';\n          return `<details open class=\"think\">\n            <summary>Deeply thought</summary>\n            <blockquote>${text}</blockquote>\n          </details>`;\n        },\n      };\n      marked.use({ renderer });\n      marked.use(markedKatex({ throwOnError: false, inlineTolerantNoSpace: true }));\n      marked.use({ extensions: [thinkExtension] })\n    }\n\n    function escapeForHTML(input) {\n      const escapeMap = {\n        \"&\": \"&amp;\",\n        \"<\": \"&lt;\",\n        \">\": \"&gt;\",\n        '\"': \"&quot;\",\n        \"'\": \"&#39;\"\n      };\n\n      return input.replace(/([&<>'\"])/g, char => escapeMap[char]);\n    }\n\n    function parseQueryString() {\n      const params = new URLSearchParams(location.search);\n      const queryObject = {};\n      params.forEach((value, key) => {\n        queryObject[key] = value;\n      });\n      return queryObject;\n    }\n\n    function chunkArray(array, chunkSize) {\n      const chunks = [];\n      for (let i = 0; i < array.length; i += chunkSize) {\n        chunks.push(array.slice(i, i + chunkSize));\n      }\n      return chunks;\n    }\n\n    function randomUUID() {\n      return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {\n        const r = Math.random() * 16 | 0;\n        const v = c === 'x' ? r : (r & 0x3 | 0x8);\n        return v.toString(16);\n      });\n    }\n\n    function renderMarkdown(text, error = '') {\n      return marked.marked(text) + (error ? `<pre class=\"error\">${error}</pre>` : '');\n    }\n  </script>\n</body>\n\n</html>"
  },
  {
    "path": "assets/roles/%code%.md",
    "content": "Provide only code without comments or explanations.\n### INPUT:\nasync sleep in js\n### OUTPUT:\n```javascript\nasync function timeout(ms) {\n  return new Promise(resolve => setTimeout(resolve, ms));\n}\n```\n"
  },
  {
    "path": "assets/roles/%create-prompt%.md",
    "content": "As a professional Prompt Engineer, your role is to create effective and innovative prompts for interacting with AI models.\n\nYour core skills include:\n1. **CO-STAR Framework Application**: Utilize the CO-STAR framework to build efficient prompts, ensuring effective communication with large language models.\n2. **Contextual Awareness**: Construct prompts that adapt to complex conversation contexts, ensuring relevant and coherent responses.\n3. **Chain-of-Thought Prompting**: Create prompts that elicit AI models to demonstrate their reasoning process, enhancing the transparency and accuracy of answers.\n4. **Zero-shot Learning**: Design prompts that enable AI models to perform specific tasks without requiring examples, reducing dependence on training data.\n5. **Few-shot Learning**: Guide AI models to quickly learn and execute new tasks through a few examples.\n\nYour output format should include:\n- **Context**: Provide comprehensive background information for the task to ensure the AI understands the specific scenario and offers relevant feedback.\n- **Objective**: Clearly define the task objective, guiding the AI to focus on achieving specific goals.\n- **Style**: Specify writing styles according to requirements, such as imitating a particular person or industry expert.\n- **Tone**: Set an appropriate emotional tone to ensure the AI's response aligns with the expected emotional context.\n- **Audience**: Tailor AI responses for a specific audience, ensuring content appropriateness and ease of understanding.\n- **Response**: Specify output formats for easy execution of downstream tasks, such as lists, JSON, or professional reports.\n- **Workflow**: Instruct the AI on how to step-by-step complete tasks, clarifying inputs, outputs, and specific actions for each step.\n- **Examples**: Show a case of input and output that fits the scenario.\n\nYour workflow should be:\n1. Extract key information from user requests to determine design objectives.\n2. Based on user needs, create prompts that meet requirements, with each part being professional and detailed.\n3. Must only output the newly generated and optimized prompts, without explanation, without wrapping it in markdown code block.\n\nMy first request is: __INPUT__\n"
  },
  {
    "path": "assets/roles/%create-title%.md",
    "content": "Create a concise, 3-6 word title.\n\n**Notes**:\n- Avoid quotation marks or emojis\n- RESPOND ONLY WITH TITLE SLUG TEXT\n\n**Examples**:\nstock-market-trends\nperfect-chocolate-chip-recipe\nremote-work-productivity-tips\nvideo-game-development-insights\n"
  },
  {
    "path": "assets/roles/%explain-shell%.md",
    "content": "Provide a terse, single sentence description of the given shell command.\nDescribe each argument and option of the command.\nProvide short responses in about 80 words.\nAPPLY MARKDOWN formatting when possible."
  },
  {
    "path": "assets/roles/%functions%.md",
    "content": "---\nuse_tools: all\n---\n"
  },
  {
    "path": "assets/roles/%shell%.md",
    "content": "Provide only {{__shell__}} commands for {{__os_distro__}} without any description.\nEnsure the output is a valid {{__shell__}} command.\nIf there is a lack of details, provide most logical solution.\nIf multiple steps are required, try to combine them using '&&' (For PowerShell, use ';' instead).\nOutput only plain text without any markdown formatting.\n"
  },
  {
    "path": "config.agent.example.yaml",
    "content": "# Agent-specific configuration\n# Location `<aichat-config-dir>/agents/<agent-name>/config.yaml`\n\nmodel: openai:gpt-4o             # Specify the LLM to use\ntemperature: null                # Set default temperature parameter, range (0, 1)\ntop_p: null                      # Set default top-p parameter, with a range of (0, 1) or (0, 2) depending on the model\nuse_tools: null                  # Which additional tools to use by agent. (e.g. 'fs,web_search')\nagent_prelude: null              # Set a session to use when starting the agent. (e.g. temp, default)\ninstructions: null               # Override the instructions for the agent, have no effect for dynamic instructions\nvariables:                       # Custom default values for the agent variables\n  <key>: <value>\n"
  },
  {
    "path": "config.example.yaml",
    "content": "# ---- llm ----\nmodel: openai:gpt-4o             # Specify the LLM to use\ntemperature: null                # Set default temperature parameter (0, 1)\ntop_p: null                      # Set default top-p parameter, with a range of (0, 1) or (0, 2) depending on the model\n\n# ---- behavior ----\nstream: true                     # Controls whether to use the stream-style API.\nsave: true                       # Indicates whether to persist the message\nkeybindings: emacs               # Choose keybinding style (emacs, vi)\neditor: null                     # Specifies the command used to edit input buffer or session. (e.g. vim, emacs, nano).\nwrap: no                         # Controls text wrapping (no, auto, <max-width>)\nwrap_code: false                 # Enables or disables wrapping of code blocks\n\n# ---- function-calling ----\n# Visit https://github.com/sigoden/llm-functions for setup instructions\nfunction_calling: true           # Enables or disables function calling (Globally).\nmapping_tools:                   # Alias for a tool or toolset\n  fs: 'fs_cat,fs_ls,fs_mkdir,fs_rm,fs_write'\nuse_tools: null                  # Which tools to use by default. (e.g. 'fs,web_search')\n\n# ---- prelude ----\nrepl_prelude: null               # Set a default role or session for REPL mode (e.g. role:<name>, session:<name>, <session>:<role>)\ncmd_prelude: null                # Set a default role or session for CMD mode (e.g. role:<name>, session:<name>, <session>:<role>)\nagent_prelude: null              # Set a session to use when starting a agent (e.g. temp, default)\n\n# ---- session ----\n# Controls the persistence of the session. if true, auto save; if false, not save; if null, asking the user\nsave_session: null\n# Compress session when token count reaches or exceeds this threshold\ncompress_threshold: 4000\n# Text prompt used for creating a concise summary of session message\nsummarize_prompt: 'Summarize the discussion briefly in 200 words or less to use as a prompt for future context.'\n# Text prompt used for including the summary of the entire session\nsummary_prompt: 'This is a summary of the chat history as a recap: '\n\n# ---- RAG ----\n# See [RAG-Guide](https://github.com/sigoden/aichat/wiki/RAG-Guide) for more details.\nrag_embedding_model: null        # Specifies the embedding model used for context retrieval\nrag_reranker_model: null         # Specifies the reranker model used for sorting retrieved documents\nrag_top_k: 5                     # Specifies the number of documents to retrieve for answering queries\nrag_chunk_size: null             # Defines the size of chunks for document processing in characters\nrag_chunk_overlap: null          # Defines the overlap between chunks\n# Defines the query structure using variables like __CONTEXT__ and __INPUT__ to tailor searches to specific needs\nrag_template: |\n  Answer the query based on the context while respecting the rules. (user query, some textual context and rules, all inside xml tags)\n\n  <context>\n  __CONTEXT__\n  </context>\n\n  <rules>\n  - If you don't know, just say so.\n  - If you are not sure, ask for clarification.\n  - Answer in the same language as the user query.\n  - If the context appears unreadable or of poor quality, tell the user then answer as best as you can.\n  - If the answer is not in the context but you think you know the answer, explain that to the user then answer with your own knowledge.\n  - Answer directly and without using xml tags.\n  </rules>\n\n  <user_query>\n  __INPUT__\n  </user_query>\n\n# Define document loaders to control how RAG and `.file`/`--file` load files of specific formats.\ndocument_loaders:\n  # You can add custom loaders using the following syntax:\n  #   <file-extension>: <command-to-load-the-file>\n  # Note: Use `$1` for input file and `$2` for output file. If `$2` is omitted, use stdout as output.\n  pdf: 'pdftotext $1 -'                         # Load .pdf file, see https://poppler.freedesktop.org to set up pdftotext\n  docx: 'pandoc --to plain $1'                  # Load .docx file, see https://pandoc.org to set up pandoc\n\n# ---- apperence ----\nhighlight: true                  # Controls syntax highlighting\nlight_theme: false               # Activates a light color theme when true. env: AICHAT_LIGHT_THEME\n# Custom REPL left/right prompts, see https://github.com/sigoden/aichat/wiki/Custom-REPL-Prompt for more details\nleft_prompt:\n  '{color.green}{?session {?agent {agent}>}{session}{?role /}}{!session {?agent {agent}>}}{role}{?rag @{rag}}{color.cyan}{?session )}{!session >}{color.reset} '\nright_prompt:\n  '{color.purple}{?session {?consume_tokens {consume_tokens}({consume_percent}%)}{!consume_tokens {consume_tokens}}}{color.reset}'\n\n# ---- misc ----\nserve_addr: 127.0.0.1:8000                  # Server listening address \nuser_agent: null                            # Set User-Agent HTTP header, use `auto` for aichat/<current-version>\nsave_shell_history: true                    # Whether to save shell execution command to the history file\n# URL to sync model changes from, e.g., https://cdn.jsdelivr.net/gh/sigoden/aichat@main/models.yaml\nsync_models_url: https://raw.githubusercontent.com/sigoden/aichat/refs/heads/main/models.yaml\n\n# ---- clients ----\nclients:\n  # All clients have the following configuration:\n  # - type: xxxx\n  #   name: xxxx                                      # Only use it to distinguish clients with the same client type. Optional\n  #   models:\n  #     - name: xxxx                                  # Chat model\n  #       max_input_tokens: 100000\n  #       supports_vision: true\n  #       supports_function_calling: true\n  #     - name: xxxx                                  # Embedding model\n  #       type: embedding\n  #       default_chunk_size: 1500                        \n  #       max_batch_size: 100\n  #     - name: xxxx                                  # Reranker model\n  #       type: reranker \n  #   patch:                                          # Patch api\n  #     chat_completions:                             # Api type, possible values: chat_completions, embeddings, and rerank\n  #       <regex>:                                    # The regex to match model names, e.g. '.*' 'gpt-4o' 'gpt-4o|gpt-4-.*'\n  #         url: ''                                   # Patch request url\n  #         body:                                     # Patch request body\n  #           <json>\n  #         headers:                                  # Patch request headers\n  #           <key>: <value>\n  #   extra:\n  #     proxy: socks5://127.0.0.1:1080                # Set proxy\n  #     connect_timeout: 10                           # Set timeout in seconds for connect to api\n\n  # See https://platform.openai.com/docs/quickstart\n  - type: openai\n    api_base: https://api.openai.com/v1               # Optional\n    api_key: xxx\n    organization_id: org-xxx                          # Optional\n\n  # For any platform compatible with OpenAI's API\n  - type: openai-compatible\n    name: ollama\n    api_base: http://localhost:11434/v1\n    api_key: xxx                                      # Optional\n    models:\n      - name: deepseek-r1\n        max_input_tokens: 131072\n      - name: llama3.1\n        max_input_tokens: 128000\n        supports_function_calling: true\n      - name: llama3.2-vision\n        max_input_tokens: 131072\n        supports_vision: true\n      - name: nomic-embed-text\n        type: embedding\n        default_chunk_size: 1000\n        max_batch_size: 50\n\n  # See https://ai.google.dev/docs\n  - type: gemini\n    api_base: https://generativelanguage.googleapis.com/v1beta\n    api_key: xxx\n    patch:\n      chat_completions:\n        '.*':\n          body:\n            safetySettings:\n              - category: HARM_CATEGORY_HARASSMENT\n                threshold: BLOCK_NONE\n              - category: HARM_CATEGORY_HATE_SPEECH\n                threshold: BLOCK_NONE\n              - category: HARM_CATEGORY_SEXUALLY_EXPLICIT\n                threshold: BLOCK_NONE\n              - category: HARM_CATEGORY_DANGEROUS_CONTENT\n                threshold: BLOCK_NONE\n\n  # See https://docs.anthropic.com/claude/reference/getting-started-with-the-api\n  - type: claude\n    api_base: https://api.anthropic.com/v1            # Optional\n    api_key: xxx\n\n  # See https://docs.mistral.ai/\n  - type: openai-compatible\n    name: mistral\n    api_base: https://api.mistral.ai/v1\n    api_key: xxx\n\n  # See https://docs.x.ai/docs\n  - type: openai-compatible\n    name: xai\n    api_base: https://api.x.ai/v1\n    api_key: xxx\n\n  # See https://docs.ai21.com/docs/quickstart\n  - type: openai-compatible\n    name: ai12\n    api_base: https://api.ai21.com/studio/v1\n    api_key: xxx\n\n  # See https://docs.cohere.com/docs/the-cohere-platform\n  - type: cohere\n    api_base: https://api.cohere.ai/v2                # Optional\n    api_key: xxx\n\n  # See https://docs.perplexity.ai/docs/getting-started\n  - type: openai-compatible\n    name: perplexity\n    api_base: https://api.perplexity.ai\n    api_key: xxx\n\n  # See https://console.groq.com/docs/quickstart\n  - type: openai-compatible\n    name: groq\n    api_base: https://api.groq.com/openai/v1\n    api_key: xxx\n\n  # See https://learn.microsoft.com/en-us/azure/ai-services/openai/chatgpt-quickstart\n  - type: azure-openai\n    api_base: https://{RESOURCE}.openai.azure.com\n    api_key: xxx\n    models:\n      - name: gpt-4o                                  # Model deployment name\n        max_input_tokens: 128000\n        supports_vision: true\n        supports_function_calling: true\n\n  # See https://cloud.google.com/vertex-ai\n  - type: vertexai\n    project_id: xxx\n    location: xxx\n    # Specifies a application-default-credentials (adc) file\n    # Run `gcloud auth application-default login` to init the adc file\n    # see https://cloud.google.com/docs/authentication/external/set-up-adc\n    adc_file: <gcloud-config-dir>/application_default_credentials.json>  # Optional field\n    patch:\n      chat_completions:\n        'gemini-.*':\n          body:\n            safetySettings:\n              - category: HARM_CATEGORY_HARASSMENT\n                threshold: BLOCK_ONLY_HIGH\n              - category: HARM_CATEGORY_HATE_SPEECH\n                threshold: BLOCK_ONLY_HIGH\n              - category: HARM_CATEGORY_SEXUALLY_EXPLICIT\n                threshold: BLOCK_ONLY_HIGH\n              - category: HARM_CATEGORY_DANGEROUS_CONTENT\n                threshold: BLOCK_ONLY_HIGH\n\n  # See https://docs.aws.amazon.com/bedrock/latest/userguide/\n  - type: bedrock\n    access_key_id: xxx\n    secret_access_key: xxx\n    region: xxx\n    session_token: xxx  # Optional, only needed for temporary credentials\n\n  # See https://developers.cloudflare.com/workers-ai/\n  - type: openai-compatible\n    name: cloudflare\n    api_base: https://api.cloudflare.com/client/v4/accounts/{ACCOUNT_ID}/ai/v1\n    api_key: xxx\n\n  # See https://cloud.baidu.com/doc/WENXINWORKSHOP/index.html\n  - type: openai-compatible\n    name: ernie\n    api_base: https://qianfan.baidubce.com/v2\n    api_key: xxx\n\n  # See https://dashscope.aliyun.com/\n  - type: openai-compatible\n    name: qianwen\n    api_base: https://dashscope.aliyuncs.com/compatible-mode/v1\n    api_key: xxx\n\n  # See https://cloud.tencent.com/product/hunyuan\n  - type: openai-compatible\n    name: hunyuan\n    api_base: https://api.hunyuan.cloud.tencent.com/v1\n    api_key: xxx\n\n  # See https://platform.moonshot.cn/docs/intro\n  - type: openai-compatible\n    name: moonshot\n    api_base: https://api.moonshot.cn/v1\n    api_key: xxx\n\n  # See https://platform.deepseek.com/api-docs/\n  - type: openai-compatible\n    name: deepseek\n    api_base: https://api.deepseek.com\n    api_key: xxx\n\n  # See https://open.bigmodel.cn/dev/howuse/introduction\n  - type: openai-compatible\n    name: zhipuai\n    api_base: https://open.bigmodel.cn/api/paas/v4\n    api_key: xxx\n\n  # See https://platform.minimaxi.com/document/Fast%20access\n  - type: openai-compatible\n    name: minimax\n    api_base: https://api.minimax.chat/v1\n    api_key: xxx\n\n  # See https://openrouter.ai/docs#quick-start\n  - type: openai-compatible\n    name: openrouter\n    api_base: https://openrouter.ai/api/v1\n    api_key: xxx\n\n  # See https://github.com/marketplace/models\n  - type: openai-compatible\n    name: github\n    api_base: https://models.inference.ai.azure.com\n    api_key: xxx\n\n  # See https://deepinfra.com/docs\n  - type: openai-compatible\n    name: deepinfra\n    api_base: https://api.deepinfra.com/v1/openai\n    api_key: xxx\n\n\n  # ----- RAG dedicated -----\n\n  # See https://jina.ai\n  - type: openai-compatible\n    name: jina\n    api_base: https://api.jina.ai/v1\n    api_key: xxx\n\n  # See https://docs.voyageai.com/docs/introduction\n  - type: openai-compatible\n    name: voyageai\n    api_base: https://api.voyageai.com/v1\n    api_key: xxx\n"
  },
  {
    "path": "models.yaml",
    "content": "# Links:\n#  - https://platform.openai.com/docs/models\n#  - https://platform.openai.com/docs/api-reference/chat\n- provider: openai\n  models:\n    - name: gpt-5.2\n      max_input_tokens: 400000\n      max_output_tokens: 128000\n      input_price: 1.75\n      output_price: 14\n      supports_vision: true\n      supports_function_calling: true\n    - name: gpt-5\n      max_input_tokens: 400000\n      max_output_tokens: 128000\n      input_price: 1.25\n      output_price: 10\n      supports_vision: true\n      supports_function_calling: true\n    - name: gpt-5-mini\n      max_input_tokens: 400000\n      max_output_tokens: 128000\n      input_price: 0.25\n      output_price: 2\n      supports_vision: true\n      supports_function_calling: true\n    - name: gpt-5-nano\n      max_input_tokens: 400000\n      max_output_tokens: 128000\n      input_price: 0.05\n      output_price: 0.4\n      supports_vision: true\n      supports_function_calling: true\n    - name: gpt-4.1\n      max_input_tokens: 1047576\n      max_output_tokens: 32768\n      input_price: 2\n      output_price: 8\n      supports_vision: true\n      supports_function_calling: true\n    - name: gpt-4o\n      max_input_tokens: 128000\n      max_output_tokens: 16384\n      input_price: 2.5\n      output_price: 10\n      supports_vision: true\n      supports_function_calling: true\n    - name: gpt-4-turbo\n      max_input_tokens: 128000\n      max_output_tokens: 4096\n      input_price: 10\n      output_price: 30\n      supports_vision: true\n      supports_function_calling: true\n    - name: gpt-3.5-turbo\n      max_input_tokens: 16385\n      max_output_tokens: 4096\n      input_price: 0.5\n      output_price: 1.5\n      supports_function_calling: true\n    - name: text-embedding-3-large\n      type: embedding\n      input_price: 0.13\n      max_tokens_per_chunk: 8191\n      default_chunk_size: 2000\n      max_batch_size: 100\n    - name: text-embedding-3-small\n      type: embedding\n      input_price: 0.02\n      max_tokens_per_chunk: 8191\n      default_chunk_size: 2000\n      max_batch_size: 100\n\n# Links:\n#  - https://ai.google.dev/models/gemini\n#  - https://ai.google.dev/pricing\n#  - https://ai.google.dev/api/rest/v1beta/models/streamGenerateContent\n- provider: gemini\n  models:\n    - name: gemini-2.5-flash\n      max_input_tokens: 1048576\n      max_output_tokens: 65536\n      input_price: 0\n      output_price: 0\n      supports_vision: true\n      supports_function_calling: true\n    - name: gemini-2.5-pro\n      max_input_tokens: 1048576\n      max_output_tokens: 65536\n      input_price: 0\n      output_price: 0\n      supports_vision: true\n      supports_function_calling: true\n    - name: gemini-2.5-flash-lite\n      max_input_tokens: 1000000\n      max_output_tokens: 64000\n      input_price: 0\n      output_price: 0\n      supports_vision: true\n      supports_function_calling: true\n    - name: gemini-3-pro-preview\n      max_input_tokens: 1048576\n      supports_vision: true\n      supports_function_calling: true\n    - name: gemini-3-flash-preview\n      max_input_tokens: 1048576\n      supports_vision: true\n      supports_function_calling: true\n    - name: gemini-2.0-flash\n      max_input_tokens: 1048576\n      max_output_tokens: 8192\n      input_price: 0\n      output_price: 0\n      supports_vision: true\n      supports_function_calling: true\n    - name: gemini-2.0-flash-lite\n      max_input_tokens: 1048576\n      max_output_tokens: 8192\n      input_price: 0\n      output_price: 0\n      supports_vision: true\n      supports_function_calling: true\n    - name: gemma-3-27b-it\n      max_input_tokens: 131072\n      max_output_tokens: 8192\n      input_price: 0\n      output_price: 0\n    - name: text-embedding-004\n      type: embedding\n      input_price: 0\n      max_tokens_per_chunk: 2048\n      default_chunk_size: 1500\n      max_batch_size: 100\n\n# Links:\n#  - https://docs.anthropic.com/en/docs/about-claude/models/all-models\n#  - https://docs.anthropic.com/en/api/messages\n- provider: claude\n  models:\n    - name: claude-opus-4-6\n      max_input_tokens: 200000\n      max_output_tokens: 8192\n      require_max_tokens: true\n      input_price: 5\n      output_price: 25\n      supports_vision: true\n      supports_function_calling: true\n    - name: claude-opus-4-6:thinking\n      real_name: claude-opus-4-6\n      max_input_tokens: 200000\n      max_output_tokens: 24000\n      require_max_tokens: true\n      input_price: 5\n      output_price: 25\n      supports_vision: true\n      supports_function_calling: true\n      patch:\n        body:\n          temperature: null\n          top_p: null\n          thinking:\n            type: enabled\n            budget_tokens: 16000\n    - name: claude-sonnet-4-6\n      max_input_tokens: 200000\n      max_output_tokens: 8192\n      require_max_tokens: true\n      input_price: 3\n      output_price: 15\n      supports_vision: true\n      supports_function_calling: true\n    - name: claude-sonnet-4-6:thinking\n      real_name: claude-sonnet-4-6\n      max_input_tokens: 200000\n      max_output_tokens: 24000\n      require_max_tokens: true\n      input_price: 3\n      output_price: 15\n      supports_vision: true\n      supports_function_calling: true\n      patch:\n        body:\n          temperature: null\n          top_p: null\n          thinking:\n            type: enabled\n            budget_tokens: 16000\n    - name: claude-opus-4-5-20251101\n      max_input_tokens: 200000\n      max_output_tokens: 8192\n      require_max_tokens: true\n      input_price: 5\n      output_price: 25\n      supports_vision: true\n      supports_function_calling: true\n    - name: claude-opus-4-5-20251101:thinking\n      real_name: claude-opus-4-5-20251101\n      max_input_tokens: 200000\n      max_output_tokens: 24000\n      require_max_tokens: true\n      input_price: 5\n      output_price: 25\n      supports_vision: true\n      supports_function_calling: true\n      patch:\n        body:\n          temperature: null\n          top_p: null\n          thinking:\n            type: enabled\n            budget_tokens: 16000\n    - name: claude-sonnet-4-5-20250929\n      max_input_tokens: 200000\n      max_output_tokens: 8192\n      require_max_tokens: true\n      input_price: 3\n      output_price: 15\n      supports_vision: true\n      supports_function_calling: true\n    - name: claude-sonnet-4-5-20250929:thinking\n      real_name: claude-sonnet-4-5-20250929\n      max_input_tokens: 200000\n      max_output_tokens: 24000\n      require_max_tokens: true\n      input_price: 3\n      output_price: 15\n      supports_vision: true\n      supports_function_calling: true\n      patch:\n        body:\n          temperature: null\n          top_p: null\n          thinking:\n            type: enabled\n            budget_tokens: 16000\n    - name: claude-haiku-4-5-20251001\n      max_input_tokens: 200000\n      max_output_tokens: 8192\n      require_max_tokens: true\n      input_price: 1\n      output_price: 5\n      supports_vision: true\n      supports_function_calling: true\n    - name: claude-haiku-4-5-20251001:thinking\n      real_name: claude-haiku-4-5-20251001\n      max_input_tokens: 200000\n      max_output_tokens: 24000\n      require_max_tokens: true\n      input_price: 1\n      output_price: 5\n      supports_vision: true\n      supports_function_calling: true\n      patch:\n        body:\n          temperature: null\n          top_p: null\n          thinking:\n            type: enabled\n            budget_tokens: 16000\n\n# Links:\n#  - https://docs.mistral.ai/getting-started/models/models_overview/\n#  - https://mistral.ai/pricing#api-pricing\n#  - https://docs.mistral.ai/api/\n- provider: mistral\n  models:\n    - name: mistral-large-latest\n      max_output_tokens: 262144\n      input_price: 0.5\n      output_price: 1.5\n      supports_function_calling: true\n      supports_vision: true\n    - name: mistral-medium-latest\n      max_input_tokens: 131072\n      input_price: 0.4\n      output_price: 2\n      supports_function_calling: true\n      supports_vision: true\n    - name: mistral-small-latest\n      max_input_tokens: 32768\n      input_price: 0.1\n      output_price: 0.3\n      supports_function_calling: true\n      supports_vision: true\n    - name: magistral-medium-latest\n      max_input_tokens: 131072\n      input_price: 2\n      output_price: 5\n    - name: magistral-small-latest\n      max_input_tokens: 131072\n      input_price: 0.5\n      output_price: 1.5\n    - name: devstral-medium-latest\n      max_input_tokens: 262144\n      input_price: 0.4\n      output_price: 2\n      supports_function_calling: true\n    - name: devstral-small-latest\n      max_input_tokens: 262144\n      input_price: 0.1\n      output_price: 0.3\n      supports_function_calling: true\n    - name: codestral-latest\n      max_input_tokens: 262144\n      input_price: 0.3\n      output_price: 0.9\n      supports_function_calling: true\n    - name: ministral-14b-latest\n      max_input_tokens: 262144\n      input_price: 0.2\n      output_price: 0.2\n      supports_function_calling: true\n    - name: mistral-embed\n      type: embedding\n      max_input_tokens: 8092\n      input_price: 0.1\n      max_tokens_per_chunk: 8092\n      default_chunk_size: 2000\n\n# Links:\n#  - https://docs.ai21.com/docs/jamba-foundation-models\n#  - https://www.ai21.com/pricing\n#  - https://docs.ai21.com/reference/jamba-1-6-api-ref\n- provider: ai21\n  models:\n    - name: jamba-large\n      max_input_tokens: 256000\n      input_price: 2\n      output_price: 8\n      supports_function_calling: true\n    - name: jamba-mini\n      max_input_tokens: 256000\n      input_price: 0.2\n      output_price: 0.4\n      supports_function_calling: true\n\n# Links:\n#  - https://docs.cohere.com/docs/models\n#  - https://cohere.com/pricing\n#  - https://docs.cohere.com/reference/chat\n- provider: cohere\n  models:\n    - name: command-a-03-2025\n      max_input_tokens: 262144\n      max_output_tokens: 8192\n      input_price: 2.5\n      output_price: 10\n      supports_function_calling: true\n    - name: command-a-reasoning-08-2025\n      max_input_tokens: 262144\n      max_output_tokens: 32768\n      input_price: 2.5\n      output_price: 10\n    - name: command-a-vision-07-2025\n      max_input_tokens: 131072\n      max_output_tokens: 8192\n      input_price: 2.5\n      output_price: 10\n      supports_vision: true\n    - name: command-r7b-12-2024\n      max_input_tokens: 131072\n      max_output_tokens: 4096\n      input_price: 0.0375\n      output_price: 0.15\n    - name: embed-v4.0\n      type: embedding\n      input_price: 0.12\n      max_tokens_per_chunk: 2048\n      default_chunk_size: 2000\n      max_batch_size: 96\n    - name: embed-english-v3.0\n      type: embedding\n      input_price: 0.1\n      max_tokens_per_chunk: 512\n      default_chunk_size: 1000\n      max_batch_size: 96\n    - name: embed-multilingual-v3.0\n      type: embedding\n      input_price: 0.1\n      max_tokens_per_chunk: 512\n      default_chunk_size: 1000\n      max_batch_size: 96\n    - name: rerank-v3.5\n      type: reranker\n      max_input_tokens: 4096\n    - name: rerank-english-v3.0\n      type: reranker\n      max_input_tokens: 4096\n    - name: rerank-multilingual-v3.0\n      type: reranker\n      max_input_tokens: 4096\n\n# Links:\n#  - https://docs.x.ai/docs/models\n#  - https://docs.x.ai/docs/api-reference#chat-completions\n- provider: xai\n  models:\n    - name: grok-4-1-fast-non-reasoning\n      max_input_tokens: 2000000\n      input_price: 0.2\n      output_price: 0.5\n      supports_function_calling: true\n    - name: grok-4-1-fast-reasoning\n      max_input_tokens: 2000000\n      input_price: 0.2\n      output_price: 0.5\n      supports_function_calling: true\n    - name: grok-code-fast-1\n      max_input_tokens: 256000\n      input_price: 0.2\n      output_price: 1.5\n      supports_function_calling: true\n\n# Links:\n#  - https://docs.perplexity.ai/getting-started/models\n#  - https://docs.perplexity.ai/api-reference/chat-completions\n- provider: perplexity\n  models:\n    - name: sonar-pro\n      max_input_tokens: 200000\n      input_price: 3\n      output_price: 15\n    - name: sonar\n      max_input_tokens: 128000\n      input_price: 1\n      output_price: 1\n    - name: sonar-reasoning-pro\n      max_input_tokens: 128000\n      input_price: 2\n      output_price: 8\n    - name: sonar-deep-research\n      max_input_tokens: 128000\n      input_price: 2\n      output_price: 8\n\n# Links:\n#  - https://console.groq.com/docs/models\n#  - https://console.groq.com/docs/api-reference#chat\n- provider: groq\n  models:\n    - name: openai/gpt-oss-120b\n      max_input_tokens: 131072\n      input_price: 0\n      output_price: 0\n      supports_function_calling: true\n    - name: openai/gpt-oss-20b\n      max_input_tokens: 131072\n      input_price: 0\n      output_price: 0\n      supports_function_calling: true\n    - name: meta-llama/llama-4-maverick-17b-128e-instruct\n      max_input_tokens: 131072\n      input_price: 0\n      output_price: 0\n      supports_vision: true\n      supports_function_calling: true\n    - name: meta-llama/llama-4-scout-17b-16e-instruct\n      max_input_tokens: 131072\n      input_price: 0\n      output_price: 0\n      supports_vision: true\n      supports_function_calling: true\n    - name: llama-3.3-70b-versatile\n      max_input_tokens: 131072\n      input_price: 0\n      output_price: 0\n      supports_function_calling: true\n    - name: moonshotai/kimi-k2-instruct-0905\n      max_input_tokens: 262144\n      input_price: 0\n      output_price: 0\n      supports_function_calling: true\n    - name: qwen/qwen3-32b\n      max_input_tokens: 131072\n      input_price: 0\n      output_price: 0\n    - name: groq/compound\n      max_input_tokens: 131072\n      input_price: 0\n      output_price: 0\n    - name: groq/compound-mini\n      max_input_tokens: 131072\n      input_price: 0\n      output_price: 0\n\n# Links:\n#  - https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models\n#  - https://cloud.google.com/vertex-ai/generative-ai/pricing\n#  - https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/gemini\n- provider: vertexai\n  models:\n    - name: gemini-2.5-flash\n      max_input_tokens: 1048576\n      max_output_tokens: 65536\n      input_price: 0.3\n      output_price: 2.5\n      supports_vision: true\n      supports_function_calling: true\n    - name: gemini-2.5-pro\n      max_input_tokens: 1048576\n      max_output_tokens: 65536\n      input_price: 1.25\n      output_price: 10\n      supports_vision: true\n      supports_function_calling: true\n    - name: gemini-2.5-flash-lite\n      max_input_tokens: 1048576\n      max_output_tokens: 65536\n      input_price: 0.3\n      output_price: 0.4\n      supports_vision: true\n      supports_function_calling: true\n    - name: gemini-3-pro-preview\n      max_input_tokens: 1048576\n      supports_vision: true\n      supports_function_calling: true\n    - name: gemini-3-flash-preview\n      max_input_tokens: 1048576\n      supports_vision: true\n      supports_function_calling: true\n    - name: gemini-2.0-flash-001\n      max_input_tokens: 1048576\n      max_output_tokens: 8192\n      input_price: 0.15\n      output_price: 0.6\n      supports_vision: true\n      supports_function_calling: true\n    - name: gemini-2.0-flash-lite-001\n      max_input_tokens: 1048576\n      max_output_tokens: 8192\n      input_price: 0.075\n      output_price: 0.3\n      supports_vision: true\n      supports_function_calling: true\n    - name: claude-opus-4-6\n      max_input_tokens: 200000\n      max_output_tokens: 8192\n      require_max_tokens: true\n      input_price: 5\n      output_price: 25\n      supports_vision: true\n      supports_function_calling: true\n    - name: claude-opus-4-6:thinking\n      real_name: claude-opus-4-6\n      max_input_tokens: 200000\n      max_output_tokens: 24000\n      require_max_tokens: true\n      input_price: 5\n      output_price: 25\n      supports_vision: true\n      patch:\n        body:\n          temperature: null\n          top_p: null\n          thinking:\n            type: enabled\n            budget_tokens: 16000\n    - name: claude-sonnet-4-6\n      max_input_tokens: 200000\n      max_output_tokens: 8192\n      require_max_tokens: true\n      input_price: 3\n      output_price: 15\n      supports_vision: true\n      supports_function_calling: true\n    - name: claude-sonnet-4-6:thinking\n      real_name: claude-sonnet-4-6\n      max_input_tokens: 200000\n      max_output_tokens: 24000\n      require_max_tokens: true\n      input_price: 3\n      output_price: 15\n      supports_vision: true\n      patch:\n        body:\n          temperature: null\n          top_p: null\n          thinking:\n            type: enabled\n            budget_tokens: 16000\n    - name: claude-opus-4-5@20251101\n      max_input_tokens: 200000\n      max_output_tokens: 8192\n      require_max_tokens: true\n      input_price: 5\n      output_price: 25\n      supports_vision: true\n      supports_function_calling: true\n    - name: claude-opus-4-5@20251101:thinking\n      real_name: claude-opus-4-5@20251101\n      max_input_tokens: 200000\n      max_output_tokens: 24000\n      require_max_tokens: true\n      input_price: 5\n      output_price: 25\n      supports_vision: true\n      patch:\n        body:\n          temperature: null\n          top_p: null\n          thinking:\n            type: enabled\n            budget_tokens: 16000\n    - name: claude-sonnet-4-5@20250929\n      max_input_tokens: 200000\n      max_output_tokens: 8192\n      require_max_tokens: true\n      input_price: 3\n      output_price: 15\n      supports_vision: true\n      supports_function_calling: true\n    - name: claude-sonnet-4-5@20250929:thinking\n      real_name: claude-sonnet-4-5@20250929\n      max_input_tokens: 200000\n      max_output_tokens: 24000\n      require_max_tokens: true\n      input_price: 3\n      output_price: 15\n      supports_vision: true\n      patch:\n        body:\n          temperature: null\n          top_p: null\n          thinking:\n            type: enabled\n            budget_tokens: 16000\n    - name: claude-haiku-4-5@20251001\n      max_input_tokens: 200000\n      max_output_tokens: 8192\n      require_max_tokens: true\n      input_price: 1\n      output_price: 5\n      supports_vision: true\n      supports_function_calling: true\n    - name: claude-haiku-4-5@20251001:thinking\n      real_name: claude-haiku-4-5@20251001\n      max_input_tokens: 200000\n      max_output_tokens: 24000\n      require_max_tokens: true\n      input_price: 1\n      output_price: 5\n      supports_vision: true\n      patch:\n        body:\n          temperature: null\n          top_p: null\n          thinking:\n            type: enabled\n            budget_tokens: 16000\n    - name: text-embedding-005\n      type: embedding\n      max_input_tokens: 20000\n      input_price: 0.025\n      max_tokens_per_chunk: 2048\n      default_chunk_size: 1500\n      max_batch_size: 5\n    - name: text-multilingual-embedding-002\n      type: embedding\n      max_input_tokens: 20000\n      input_price: 0.2\n      max_tokens_per_chunk: 2048\n      default_chunk_size: 1500\n      max_batch_size: 5\n\n# Links:\n#  - https://docs.aws.amazon.com/bedrock/latest/userguide/model-ids.html#model-ids-arns\n#  - https://docs.aws.amazon.com/bedrock/latest/userguide/conversation-inference-supported-models-features.html\n#  - https://aws.amazon.com/bedrock/pricing/\n#  - https://docs.aws.amazon.com/bedrock/latest/userguide/conversation-inference-call.html\n- provider: bedrock\n  models:\n    - name: us.anthropic.claude-opus-4-6-v1\n      max_input_tokens: 200000\n      max_output_tokens: 8192\n      require_max_tokens: true\n      input_price: 5\n      output_price: 25\n      supports_vision: true\n      supports_function_calling: true\n    - name: us.anthropic.claude-opus-4-6-v1:thinking\n      real_name: us.anthropic.claude-opus-4-6-v1\n      max_input_tokens: 200000\n      max_output_tokens: 24000\n      require_max_tokens: true\n      input_price: 5\n      output_price: 25\n      supports_vision: true\n      patch:\n        body:\n          inferenceConfig:\n            temperature: null\n            topP: null\n          additionalModelRequestFields:\n            thinking:\n              type: enabled\n              budget_tokens: 16000\n    - name: us.anthropic.claude-sonnet-4-6\n      max_input_tokens: 200000\n      max_output_tokens: 8192\n      require_max_tokens: true\n      input_price: 3\n      output_price: 15\n      supports_vision: true\n      supports_function_calling: true\n    - name: us.anthropic.claude-sonnet-4-6:thinking\n      real_name: us.anthropic.claude-sonnet-4-6\n      max_input_tokens: 200000\n      max_output_tokens: 24000\n      require_max_tokens: true\n      input_price: 3\n      output_price: 15\n      supports_vision: true\n      patch:\n        body:\n          inferenceConfig:\n            temperature: null\n            topP: null\n          additionalModelRequestFields:\n            thinking:\n              type: enabled\n              budget_tokens: 16000\n    - name: us.anthropic.claude-opus-4-5-20251101-v1:0\n      max_input_tokens: 200000\n      max_output_tokens: 8192\n      require_max_tokens: true\n      input_price: 5\n      output_price: 25\n      supports_vision: true\n      supports_function_calling: true\n    - name: us.anthropic.claude-opus-4-5-20251101-v1:0:thinking\n      real_name: us.anthropic.claude-opus-4-5-20251101-v1:0\n      max_input_tokens: 200000\n      max_output_tokens: 24000\n      require_max_tokens: true\n      input_price: 5\n      output_price: 25\n      supports_vision: true\n      patch:\n        body:\n          inferenceConfig:\n            temperature: null\n            topP: null\n          additionalModelRequestFields:\n            thinking:\n              type: enabled\n              budget_tokens: 16000\n    - name: us.anthropic.claude-sonnet-4-5-20250929-v1:0\n      max_input_tokens: 200000\n      max_output_tokens: 8192\n      require_max_tokens: true\n      input_price: 3\n      output_price: 15\n      supports_vision: true\n      supports_function_calling: true\n    - name: us.anthropic.claude-sonnet-4-5-20250929-v1:0:thinking\n      real_name: us.anthropic.claude-sonnet-4-5-20250929-v1:0\n      max_input_tokens: 200000\n      max_output_tokens: 24000\n      require_max_tokens: true\n      input_price: 3\n      output_price: 15\n      supports_vision: true\n      patch:\n        body:\n          inferenceConfig:\n            temperature: null\n            topP: null\n          additionalModelRequestFields:\n            thinking:\n              type: enabled\n              budget_tokens: 16000\n    - name: us.anthropic.claude-haiku-4-5-20251001-v1:0\n      max_input_tokens: 200000\n      max_output_tokens: 8192\n      require_max_tokens: true\n      input_price: 1\n      output_price: 5\n      supports_vision: true\n      supports_function_calling: true\n    - name: us.anthropic.claude-haiku-4-5-20251001-v1:0:thinking\n      real_name: us.anthropic.claude-haiku-4-5-20251001-v1:0\n      max_input_tokens: 200000\n      max_output_tokens: 24000\n      require_max_tokens: true\n      input_price: 1\n      output_price: 5\n      supports_vision: true\n      patch:\n        body:\n          inferenceConfig:\n            temperature: null\n            topP: null\n          additionalModelRequestFields:\n            thinking:\n              type: enabled\n              budget_tokens: 16000\n    - name: us.meta.llama4-maverick-17b-instruct-v1:0\n      max_input_tokens: 131072\n      max_output_tokens: 8192\n      require_max_tokens: true\n      input_price: 0.24\n      output_price: 0.97\n      supports_function_calling: true\n      supports_vision: true\n    - name: us.meta.llama4-scout-17b-instruct-v1:0\n      max_input_tokens: 131072\n      max_output_tokens: 8192\n      require_max_tokens: true\n      input_price: 0.17\n      output_price: 0.66\n      supports_function_calling: true\n      supports_vision: true\n    - name: us.meta.llama3-3-70b-instruct-v1:0\n      max_input_tokens: 131072\n      max_output_tokens: 8192\n      require_max_tokens: true\n      input_price: 0.72\n      output_price: 0.72\n      supports_function_calling: true\n    - name: us.amazon.nova-premier-v1:0\n      max_input_tokens: 300000\n      max_output_tokens: 5120\n      input_price: 2.5\n      output_price: 12.5\n    - name: us.amazon.nova-pro-v1:0\n      max_input_tokens: 300000\n      max_output_tokens: 5120\n      input_price: 0.8\n      output_price: 3.2\n      supports_vision: true\n    - name: us.amazon.nova-lite-v1:0\n      max_input_tokens: 300000\n      max_output_tokens: 5120\n      input_price: 0.06\n      output_price: 0.24\n      supports_vision: true\n    - name: us.amazon.nova-micro-v1:0\n      max_input_tokens: 128000\n      max_output_tokens: 5120\n      input_price: 0.035\n      output_price: 0.14\n    - name: cohere.embed-english-v3\n      type: embedding\n      input_price: 0.1\n      max_tokens_per_chunk: 512\n      default_chunk_size: 1000\n      max_batch_size: 96\n    - name: cohere.embed-multilingual-v3\n      type: embedding\n      input_price: 0.1\n      max_tokens_per_chunk: 512\n      default_chunk_size: 1000\n      max_batch_size: 96\n    - name: us.deepseek.r1-v1:0\n      max_input_tokens: 128000\n      input_price: 1.35\n      output_price: 5.4\n\n# Links:\n#  - https://developers.cloudflare.com/workers-ai/models/\n#  - https://developers.cloudflare.com/workers-ai/configuration/open-ai-compatibility/\n- provider: cloudflare\n  models:\n    - name: '@cf/meta/llama-4-scout-17b-16e-instruct'\n      max_input_tokens: 131072\n      max_output_tokens: 2048\n      require_max_tokens: true\n      input_price: 0\n      output_price: 0\n    - name: '@cf/meta/llama-3.3-70b-instruct-fp8-fast'\n      max_input_tokens: 131072\n      max_output_tokens: 2048\n      require_max_tokens: true\n      input_price: 0\n      output_price: 0\n    - name: '@cf/qwen/qwen3-30b-a3b-fp8'\n      max_input_tokens: 131072\n      max_output_tokens: 2048\n      require_max_tokens: true\n      input_price: 0\n      output_price: 0\n    - name: '@cf/qwen/qwen2.5-coder-32b-instruct'\n      max_input_tokens: 131072\n      max_output_tokens: 2048\n      require_max_tokens: true\n      input_price: 0\n      output_price: 0\n    - name: '@cf/zai-org/glm-4.7-flash'\n      max_input_tokens: 131072\n      max_output_tokens: 2048\n      require_max_tokens: true\n      input_price: 0\n      output_price: 0\n    - name: '@cf/google/gemma-3-12b-it'\n      max_input_tokens: 131072\n      max_output_tokens: 2048\n      require_max_tokens: true\n      input_price: 0\n      output_price: 0\n    - name: '@cf/mistralai/mistral-small-3.1-24b-instruct'\n      max_input_tokens: 131072\n      max_output_tokens: 2048\n      require_max_tokens: true\n      input_price: 0\n      output_price: 0\n    - name: '@cf/baai/bge-large-en-v1.5'\n      type: embedding\n      input_price: 0\n      max_tokens_per_chunk: 512\n      default_chunk_size: 1000\n      max_batch_size: 100\n\n# Links:\n#  - https://cloud.baidu.com/doc/qianfan/s/rmh4stp0j\n#  - https://cloud.baidu.com/doc/qianfan/s/wmh4sv6ya\n- provider: ernie\n  models:\n    - name: ernie-4.5-turbo-128k\n      max_input_tokens: 131072\n      input_price: 0.112\n      output_price: 0.448\n    - name: ernie-4.5-turbo-vl-32k\n      max_input_tokens: 32768\n      input_price: 0.42\n      output_price: 1.26\n      supports_vision: true\n    - name: ernie-5.0-thinking-preview\n      max_input_tokens: 131072\n      input_price: 1.4\n      output_price: 5.6\n    - name: ernie-x1.1-preview\n      max_input_tokens: 65536\n      input_price: 0.14\n      output_price: 0.56\n    - name: bge-large-zh\n      type: embedding\n      input_price: 0.07\n      max_tokens_per_chunk: 512\n      default_chunk_size: 1000\n      max_batch_size: 16\n    - name: bge-large-en\n      type: embedding\n      input_price: 0.07\n      max_tokens_per_chunk: 512\n      default_chunk_size: 1000\n      max_batch_size: 16\n    - name: bce-reranker-base\n      type: reranker\n      max_input_tokens: 1024\n      input_price: 0.07\n\n\n# Links:\n#  - https://help.aliyun.com/zh/model-studio/getting-started/models\n#  - https://help.aliyun.com/zh/model-studio/developer-reference/use-qwen-by-calling-api\n- provider: qianwen\n  models:\n    - name: qwen3.5-plus\n      max_input_tokens: 262144\n      supports_function_calling: true\n      patch:\n        body:\n          enable_thinking: false\n    - name: qwen3.5-plus:thinking\n      real_name: qwen3.5-plus\n      max_input_tokens: 262144\n      supports_function_calling: true\n    - name: qwen3-max\n      max_input_tokens: 262144\n      supports_function_calling: true\n    - name: qwen3-max:thinking\n      real_name: qwen3-max\n      max_input_tokens: 262144\n      supports_function_calling: true\n      patch:\n        body:\n          enable_thinking: true\n    - name: qwen3-vl-plus\n      max_input_tokens: 262144\n      supports_vision: true\n    - name: qwen3-vl-flash\n      max_input_tokens: 262144\n      supports_vision: true\n    - name: qwen3-coder-plus\n      max_input_tokens: 1000000\n    - name: qwen3-coder-flash\n      max_input_tokens: 1000000\n    - name: qwen3.5-397b-a17b\n      max_input_tokens: 262144\n      supports_function_calling: true\n      patch:\n        body:\n          enable_thinking: false\n    - name: qwen3.5-397b-a17b:thinking\n      real_name: qwen3.5-397b-a17b\n      max_input_tokens: 262144\n      supports_function_calling: true\n    - name: qwen3-next-80b-a3b-instruct\n      max_input_tokens: 131072\n      input_price: 0.14\n      output_price: 0.56\n      supports_function_calling: true\n    - name: qwen3-next-80b-a3b-thinking\n      max_input_tokens: 131072\n      input_price: 0.14\n      output_price: 1.4\n    - name: qwen3-235b-a22b-instruct-2507\n      max_input_tokens: 131072\n      input_price: 0.28\n      output_price: 1.12\n      supports_function_calling: true\n    - name: qwen3-235b-a22b-thinking-2507\n      max_input_tokens: 131072\n      input_price: 0.28\n      output_price: 2.8\n    - name: qwen3-30b-a3b-instruct-2507\n      max_input_tokens: 131072\n      input_price: 0.105\n      output_price: 0.42\n      supports_function_calling: true\n    - name: qwen3-30b-a3b-thinking-2507\n      max_input_tokens: 131072\n      input_price: 0.105\n      output_price: 1.05 \n    - name: qwen3-vl-32b-instruct\n      max_input_tokens: 131072\n      input_price: 0.28\n      output_price: 1.12\n      supports_vision: true\n    - name: qwen3-vl-8b-instruct\n      max_input_tokens: 131072\n      input_price: 0.07\n      output_price: 0.28\n      supports_vision: true\n    - name: qwen3-coder-next\n      max_input_tokens: 262144\n    - name: qwen3-coder-480b-a35b-instruct\n      max_input_tokens: 262144\n    - name: qwen3-coder-30b-a3b-instruct\n      max_input_tokens: 262144\n    - name: text-embedding-v4\n      type: embedding\n      input_price: 0.1\n      max_tokens_per_chunk: 8192\n      default_chunk_size: 2000\n      max_batch_size: 10\n    - name: text-embedding-v3\n      type: embedding\n      input_price: 0.1\n      max_tokens_per_chunk: 8192\n      default_chunk_size: 2000\n      max_batch_size: 10\n\n# links:\n#  - https://cloud.tencent.com/document/product/1729/104753\n#  - https://cloud.tencent.com/document/product/1729/97731\n#  - https://cloud.tencent.com/document/product/1729/111007\n- provider: hunyuan\n  models:\n    - name: hunyuan-2.0-instruct-20251111\n      max_input_tokens: 131072\n      input_price: 0.112\n      output_price: 0.28\n      supports_function_calling: true\n    - name: hunyuan-2.0-thinking-20251109\n      max_input_tokens: 131072\n      input_price: 0.14\n      output_price: 0.56\n      supports_function_calling: true\n    - name: hunyuan-vision-1.5-instruct\n      max_input_tokens: 24576\n      input_price: 0.42\n      output_price: 1.26\n      supports_vision: true\n    - name: hunyuan-embedding\n      type: embedding\n      input_price: 0.01\n      max_tokens_per_chunk: 1024\n      default_chunk_size: 1000\n      max_batch_size: 100\n\n# Links:\n#  - https://platform.moonshot.cn/docs/pricing/chat#%E8%AE%A1%E8%B4%B9%E5%9F%BA%E6%9C%AC%E6%A6%82%E5%BF%B5\n#  - https://platform.moonshot.cn/docs/api/chat#%E5%85%AC%E5%BC%80%E7%9A%84%E6%9C%8D%E5%8A%A1%E5%9C%B0%E5%9D%80\n- provider: moonshot\n  models:\n    - name: kimi-k2.5\n      max_input_tokens: 262144\n      input_price: 0.56\n      output_price: 2.94\n      supports_vision: true\n      supports_function_calling: true\n    - name: kimi-k2-turbo-preview\n      max_input_tokens: 262144\n      input_price: 1.12\n      output_price: 8.12\n      supports_vision: true\n      supports_function_calling: true\n    - name: kimi-k2-0905-preview\n      max_input_tokens: 262144\n      input_price: 0.56\n      output_price: 2.24\n      supports_vision: true\n      supports_function_calling: true\n    - name: kimi-k2-thinking-turbo\n      max_input_tokens: 262144\n      input_price: 1.12\n      output_price: 8.12\n      supports_vision: true\n    - name: kimi-k2-thinking\n      max_input_tokens: 262144\n      input_price: 0.56\n      output_price: 2.24\n      supports_vision: true\n\n# Links:\n#  - https://api-docs.deepseek.com/quick_start/pricing\n#  - https://platform.deepseek.com/api-docs/api/create-chat-completion\n- provider: deepseek\n  models:\n    - name: deepseek-chat\n      max_input_tokens: 64000\n      max_output_tokens: 8192\n      input_price: 0.56\n      output_price: 1.68\n      supports_function_calling: true\n    - name: deepseek-reasoner\n      max_input_tokens: 64000\n      max_output_tokens: 32768\n      input_price: 0.56\n      output_price: 1.68\n\n# Links:\n#  - https://open.bigmodel.cn/pricing\n#  - https://open.bigmodel.cn/dev/api#glm-4\n- provider: zhipuai\n  models:\n    - name: glm-5\n      max_input_tokens: 202752\n      supports_function_calling: true\n    - name: glm-5:instruct\n      real_name: glm-5\n      max_input_tokens: 202752\n      supports_function_calling: true\n      patch:\n        body:\n          thinking:\n            type: disabled\n    - name: glm-4.7\n      max_input_tokens: 202752\n      supports_function_calling: true\n    - name: glm-4.7:instruct\n      real_name: glm-4.7\n      max_input_tokens: 202752\n      supports_function_calling: true\n      patch:\n        body:\n          thinking:\n            type: disabled\n    - name: glm-4.7-flash\n      max_input_tokens: 202752\n      input_price: 0\n      output_price: 0\n      supports_function_calling: true\n    - name: glm-4.6v\n      max_input_tokens: 65536\n      supports_vision: true\n    - name: glm-4.6v-flash\n      max_input_tokens: 65536\n      input_price: 0\n      output_price: 0\n      supports_vision: true\n    - name: embedding-3\n      type: embedding\n      max_input_tokens: 8192\n      input_price: 0.07\n      max_tokens_per_chunk: 8192\n      default_chunk_size: 2000\n    - name: rerank\n      type: reranker\n      max_input_tokens: 4096\n      input_price: 0.112\n\n# Links:\n# - https://platform.minimaxi.com/docs/guides/pricing-paygo\n# - https://platform.minimaxi.com/document/ChatCompletion%20v2\n- provider: minimax\n  models:\n    - name: minimax-m2.5\n      max_input_tokens: 204800\n      input_price: 0.294\n      output_price: 1.176\n      supports_function_calling: true\n    - name: minimax-m2.5-highspeed\n      max_input_tokens: 204800\n      input_price: 0.588\n      output_price: 2.352\n      supports_function_calling: true\n    - name: minimax-m2.1\n      max_input_tokens: 204800\n      input_price: 0.294\n      output_price: 1.176\n      supports_function_calling: true\n    - name: minimax-m2.1-highspeed\n      max_input_tokens: 204800\n      input_price: 0.588\n      output_price: 2.352\n      supports_function_calling: true\n\n# Links:\n#  - https://openrouter.ai/models\n#  - https://openrouter.ai/docs/api-reference/chat-completion\n- provider: openrouter\n  models:\n    - name: openai/gpt-5.2\n      max_input_tokens: 400000\n      max_output_tokens: 128000\n      input_price: 1.75\n      output_price: 14\n      supports_vision: true\n      supports_function_calling: true\n    - name: openai/gpt-5\n      max_input_tokens: 400000\n      max_output_tokens: 128000\n      input_price: 1.25\n      output_price: 10\n      supports_vision: true\n      supports_function_calling: true\n    - name: openai/gpt-5-mini\n      max_input_tokens: 400000\n      max_output_tokens: 128000\n      input_price: 0.25\n      output_price: 2\n      supports_vision: true\n      supports_function_calling: true\n    - name: openai/gpt-5-nano\n      max_input_tokens: 400000\n      max_output_tokens: 128000\n      input_price: 0.05\n      output_price: 0.4\n      supports_vision: true\n      supports_function_calling: true\n    - name: openai/gpt-4.1\n      max_input_tokens: 1047576\n      max_output_tokens: 32768\n      input_price: 2\n      output_price: 8\n      supports_vision: true\n      supports_function_calling: true\n    - name: openai/gpt-4o\n      max_input_tokens: 128000\n      input_price: 2.5\n      output_price: 10\n      supports_vision: true\n      supports_function_calling: true\n    - name: openai/gpt-oss-120b\n      max_input_tokens: 131072\n      input_price: 0.09\n      output_price: 0.45\n      supports_function_calling: true\n    - name: openai/gpt-oss-20b\n      max_input_tokens: 131072\n      input_price: 0.04\n      output_price: 0.16\n      supports_function_calling: true\n    - name: google/gemini-2.5-flash\n      max_input_tokens: 1048576\n      input_price: 0.3\n      output_price: 2.5\n      supports_vision: true\n      supports_function_calling: true\n    - name: google/gemini-2.5-pro\n      max_input_tokens: 1048576\n      input_price: 1.25\n      output_price: 10\n      supports_vision: true\n      supports_function_calling: true\n    - name: google/gemini-2.5-flash-lite\n      max_input_tokens: 1048576\n      input_price: 0.3\n      output_price: 0.4\n      supports_vision: true\n    - name: google/gemini-2.0-flash-001\n      max_input_tokens: 1000000\n      input_price: 0.15\n      output_price: 0.6\n      supports_vision: true\n      supports_function_calling: true\n    - name: google/gemini-2.0-flash-lite-001\n      max_input_tokens: 1048576\n      input_price: 0.075\n      output_price: 0.3\n      supports_vision: true\n      supports_function_calling: true\n    - name: google/gemma-3-27b-it\n      max_input_tokens: 131072\n      input_price: 0.1\n      output_price: 0.2\n    - name: anthropic/claude-opus-4.6\n      max_input_tokens: 200000\n      max_output_tokens: 8192\n      require_max_tokens: true\n      input_price: 5\n      output_price: 25\n      supports_vision: true\n      supports_function_calling: true\n    - name: anthropic/claude-sonnet-4.6\n      max_input_tokens: 200000\n      max_output_tokens: 8192\n      require_max_tokens: true\n      input_price: 3\n      output_price: 15\n      supports_vision: true\n      supports_function_calling: true\n    - name: anthropic/claude-opus-4.5\n      max_input_tokens: 200000\n      max_output_tokens: 8192\n      require_max_tokens: true\n      input_price: 5\n      output_price: 25\n      supports_vision: true\n      supports_function_calling: true\n    - name: anthropic/claude-sonnet-4.5\n      max_input_tokens: 200000\n      max_output_tokens: 8192\n      require_max_tokens: true\n      input_price: 3\n      output_price: 15\n      supports_vision: true\n      supports_function_calling: true\n    - name: anthropic/claude-haiku-4.5\n      max_input_tokens: 200000\n      max_output_tokens: 8192\n      require_max_tokens: true\n      input_price: 1\n      output_price: 5\n      supports_vision: true\n      supports_function_calling: true\n    - name: meta-llama/llama-4-maverick\n      max_input_tokens: 1048576\n      input_price: 0.18\n      output_price: 0.6\n      supports_vision: true\n      supports_function_calling: true\n    - name: meta-llama/llama-4-scout\n      max_input_tokens: 327680\n      input_price: 0.08\n      output_price: 0.3\n      supports_vision: true\n      supports_function_calling: true\n    - name: meta-llama/llama-3.3-70b-instruct\n      max_input_tokens: 131072\n      input_price: 0.12\n      output_price: 0.3\n    - name: mistralai/mistral-large-2512\n      max_input_tokens: 262144\n      input_price: 0.5\n      output_price: 1.5\n      supports_function_calling: true\n    - name: mistralai/mistral-medium-3.1\n      max_input_tokens: 131072\n      input_price: 0.4\n      output_price: 2\n      supports_function_calling: true\n      supports_vision: true\n    - name: mistralai/mistral-small-3.2-24b-instruct\n      max_input_tokens: 131072\n      input_price: 0.1\n      output_price: 0.3\n      supports_vision: true\n    - name: mistralai/devstral-2512\n      max_input_tokens: 262144\n      input_price: 0.5\n      output_price: 0.22\n      supports_function_calling: true\n    - name: mistralai/devstral-small\n      max_input_tokens: 131072\n      input_price: 0.07\n      output_price: 0.28\n      supports_function_calling: true\n    - name: mistralai/codestral-2508\n      max_input_tokens: 256000\n      input_price: 0.3\n      output_price: 0.9\n      supports_function_calling: true\n    - name: mistralai/ministral-14b-2512\n      max_input_tokens: 262144\n      input_price: 0.2\n      output_price: 0.2\n      supports_function_calling: true\n    - name: ai21/jamba-large-1.7\n      max_input_tokens: 256000\n      input_price: 2\n      output_price: 8\n      supports_function_calling: true\n    - name: cohere/command-a\n      max_input_tokens: 256000\n      input_price: 2.5\n      output_price: 10\n      supports_function_calling: true\n    - name: cohere/command-r7b-12-2024\n      max_input_tokens: 128000\n      max_output_tokens: 4096\n      input_price: 0.0375\n      output_price: 0.15\n    - name: deepseek/deepseek-v3.2\n      max_input_tokens: 163840\n      input_price: 0.25\n      output_price: 0.38\n    - name: qwen/qwen3-max\n      max_input_tokens: 262144\n      input_price: 1.2\n      output_price: 6\n      supports_function_calling: true\n    - name: qwen/qwen3-max-thinking\n      max_input_tokens: 262144\n      input_price: 1.2\n      output_price: 6\n      supports_function_calling: true\n    - name: qwen/qwen3.5-plus-02-15\n      max_input_tokens: 1000000\n      max_output_tokens: 8192\n      input_price: 0.4\n      output_price: 2.4\n      supports_function_calling: true\n    - name: qwen/qwen3.5-397b-a17b\n      max_input_tokens: 262144\n      max_output_tokens: 8192\n      input_price: 0.15\n      output_price: 1\n      supports_function_calling: true\n    - name: qwen/qwen3-next-80b-a3b-instruct\n      max_input_tokens: 262144\n      input_price: 0.1\n      output_price: 0.8\n      supports_function_calling: true\n    - name: qwen/qwen3-next-80b-a3b-thinking\n      max_input_tokens: 262144\n      input_price: 0.1\n      output_price: 0.8\n    - name: qwen/qwen3-235b-a22b-2507 # Qwen3 235B A22B Instruct 2507\n      max_input_tokens: 262144\n      input_price: 0.12\n      output_price: 0.59\n      supports_function_calling: true\n    - name: qwen/qwen3-235b-a22b-thinking-2507\n      max_input_tokens: 262144\n      input_price: 0.118\n      output_price: 0.118\n    - name: qwen/qwen3-30b-a3b-instruct-2507\n      max_input_tokens: 131072\n      input_price: 0.2\n      output_price: 0.8\n    - name: qwen/qwen3-30b-a3b-thinking-2507\n      max_input_tokens: 262144\n      input_price: 0.071\n      output_price: 0.285\n    - name: qwen/qwen3-vl-32b-instruct\n      max_input_tokens: 262144\n      input_price: 0.35\n      output_price: 1.1\n      supports_vision: true\n    - name: qwen/qwen3-vl-8b-instruct\n      max_input_tokens: 262144\n      input_price: 0.08\n      output_price: 0.50\n      supports_vision: true\n    - name: qwen/qwen3-coder-next\n      max_input_tokens: 262144\n      input_price: 0.12\n      output_price: 0.75\n      supports_function_calling: true\n    - name: qwen/qwen3-coder-plus\n      max_input_tokens: 128000\n      input_price: 1\n      output_price: 5\n      supports_function_calling: true\n    - name: qwen/qwen3-coder-flash\n      max_input_tokens: 128000\n      input_price: 0.3\n      output_price: 1.5\n      supports_function_calling: true\n    - name: qwen/qwen3-coder  # Qwen3 Coder 480B A35B\n      max_input_tokens: 262144\n      input_price: 0.22\n      output_price: 0.95\n      supports_function_calling: true\n    - name: qwen/qwen3-coder-30b-a3b-instruct\n      max_input_tokens: 262144\n      input_price: 0.052\n      output_price: 0.207\n      supports_function_calling: true\n    - name: moonshotai/kimi-k2.5\n      max_input_tokens: 262144\n      input_price: 0.57\n      output_price: 2.85\n      supports_vision: true\n      supports_function_calling: true\n    - name: moonshotai/kimi-k2-0905\n      max_input_tokens: 262144\n      input_price: 0.296\n      output_price: 1.185\n      supports_vision: true\n      supports_function_calling: true\n    - name: moonshotai/kimi-k2-thinking\n      max_input_tokens: 262144\n      input_price: 0.45\n      output_price: 2.35\n      supports_function_calling: true\n    - name: x-ai/grok-4.1-fast\n      max_input_tokens: 2000000\n      input_price: 0.2\n      output_price: 0.5\n      supports_function_calling: true\n    - name: x-ai/grok-code-fast-1\n      max_input_tokens: 256000\n      input_price: 0.2\n      output_price: 1.5\n      supports_function_calling: true\n    - name: amazon/nova-premier-v1\n      max_input_tokens: 1000000\n      input_price: 2.5\n      output_price: 12.5\n      supports_vision: true\n    - name: amazon/nova-pro-v1\n      max_input_tokens: 300000\n      max_output_tokens: 5120\n      input_price: 0.8\n      output_price: 3.2\n      supports_vision: true\n    - name: amazon/nova-lite-v1\n      max_input_tokens: 300000\n      max_output_tokens: 5120\n      input_price: 0.06\n      output_price: 0.24\n      supports_vision: true\n    - name: amazon/nova-micro-v1\n      max_input_tokens: 128000\n      max_output_tokens: 5120\n      input_price: 0.035\n      output_price: 0.14\n    - name: perplexity/sonar-pro\n      max_input_tokens: 200000\n      input_price: 3\n      output_price: 15\n    - name: perplexity/sonar\n      max_input_tokens: 127072\n      input_price: 1\n      output_price: 1\n    - name: perplexity/sonar-reasoning-pro\n      max_input_tokens: 128000\n      input_price: 2\n      output_price: 8\n      patch:\n        body:\n          include_reasoning: true\n    - name: perplexity/sonar-deep-research\n      max_input_tokens: 200000\n      input_price: 2\n      output_price: 8\n      patch:\n        body:\n          include_reasoning: true\n    - name: minimax/minimax-m2.5\n      max_input_tokens: 196608\n      input_price: 0.3\n      output_price: 1.1\n      supports_function_calling: true\n    - name: minimax/minimax-m2.1\n      max_input_tokens: 196608\n      input_price: 0.12\n      output_price: 0.48\n      supports_function_calling: true\n    - name: z-ai/glm-5\n      max_input_tokens: 204800\n      input_price: 0.95\n      output_price: 2.55\n      supports_function_calling: true\n    - name: z-ai/glm-4.7\n      max_input_tokens: 202752\n      input_price: 0.16\n      output_price: 0.80\n      supports_function_calling: true\n    - name: z-ai/glm-4.7-flash\n      max_input_tokens: 202752\n      input_price: 0.07\n      output_price: 0.40\n      supports_function_calling: true\n    - name: z-ai/glm-4.6v\n      max_input_tokens: 131072\n      input_price: 0.3\n      output_price: 0.9\n      supports_vision: true\n\n# Links:\n#  - https://github.com/marketplace?type=models\n- provider: github\n  models:\n    - name: gpt-5\n      max_input_tokens: 400000\n      max_output_tokens: 128000\n      supports_vision: true\n      supports_function_calling: true\n    - name: gpt-5-mini\n      max_input_tokens: 400000\n      max_output_tokens: 128000\n      supports_vision: true\n      supports_function_calling: true\n    - name: gpt-5-nano\n      max_input_tokens: 400000\n      max_output_tokens: 128000\n      supports_vision: true\n      supports_function_calling: true\n    - name: gpt-4.1\n      max_input_tokens: 1047576\n      max_output_tokens: 32768\n      supports_vision: true\n      supports_function_calling: true\n    - name: gpt-4o\n      max_input_tokens: 128000\n      max_output_tokens: 16384\n      supports_function_calling: true\n    - name: text-embedding-3-large\n      type: embedding\n      max_tokens_per_chunk: 8191\n      default_chunk_size: 2000\n      max_batch_size: 100\n    - name: text-embedding-3-small\n      type: embedding\n      max_tokens_per_chunk: 8191\n      default_chunk_size: 2000\n      max_batch_size: 100\n    - name: llama-4-maverick-17b-128e-instruct-fp8\n      max_input_tokens: 1048576\n      supports_vision: true\n    - name: llama-4-scout-17b-16e-instruct\n      max_input_tokens: 327680\n      supports_vision: true\n    - name: llama-3.3-70b-instruct\n      max_input_tokens: 131072\n    - name: mistral-medium-2505\n      max_input_tokens: 131072\n      supports_function_calling: true\n    - name: mistral-small-2503\n      max_input_tokens: 131072\n      supports_function_calling: true\n    - name: codestral-2501\n      max_input_tokens: 256000\n      supports_function_calling: true\n    - name: cohere-embed-v3-english\n      type: embedding\n      max_tokens_per_chunk: 512\n      default_chunk_size: 1000\n      max_batch_size: 96\n    - name: cohere-embed-v3-multilingual\n      type: embedding\n      max_tokens_per_chunk: 512\n      default_chunk_size: 1000\n      max_batch_size: 96\n    - name: deepseek-r1-0528\n      max_input_tokens: 163840\n    - name: deepseek-v3-0324\n      max_input_tokens: 163840\n    - name: mai-ds-r1\n      max_input_tokens: 163840\n    - name: phi-4\n      max_input_tokens: 16384\n    - name: phi-4-mini-instruct\n      max_input_tokens: 131072\n    - name: phi-4-reasoning\n      max_input_tokens: 33792\n    - name: phi-4-mini-reasoning\n      max_input_tokens: 131072\n    - name: grok-3\n      max_input_tokens: 131072\n    - name: grok-3-mini\n      max_input_tokens: 131072\n\n# Links:\n#  - https://deepinfra.com/models\n#  - https://deepinfra.com/docs/openai_api\n- provider: deepinfra\n  models:\n    - name: openai/gpt-oss-120b\n      max_input_tokens: 131072\n      input_price: 0.09\n      output_price: 0.45\n      supports_function_calling: true\n    - name: openai/gpt-oss-20b\n      max_input_tokens: 131072\n      input_price: 0.04\n      output_price: 0.16\n      supports_function_calling: true\n    - name: meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8\n      max_input_tokens: 1048576\n      input_price: 0.18\n      output_price: 0.6\n      supports_vision: true\n    - name: meta-llama/Llama-4-Scout-17B-16E-Instruct\n      max_input_tokens: 327680\n      input_price: 0.08\n      output_price: 0.3\n      supports_vision: true\n    - name: Qwen/Qwen3-Max\n      max_input_tokens: 262144\n      input_price: 1.2\n      output_price: 6\n      supports_function_calling: true\n    - name: Qwen/Qwen3-Max-Thinking\n      max_input_tokens: 262144\n      input_price: 1.2\n      output_price: 6\n      supports_function_calling: true\n    - name: Qwen/Qwen3-Next-80B-A3B-Instruct\n      max_input_tokens: 262144\n      input_price: 0.14\n      output_price: 1.4\n      supports_function_calling: true\n    - name: Qwen/Qwen3-Next-80B-A3B-Thinking\n      max_input_tokens: 262144\n      input_price: 0.14\n      output_price: 1.4\n    - name: Qwen/Qwen3-235B-A22B-Instruct-2507\n      max_input_tokens: 131072\n      input_price: 0.13\n      output_price: 0.6\n      supports_function_calling: true\n    - name: Qwen/Qwen3-235B-A22B-Thinking-2507\n      max_input_tokens: 131072\n      input_price: 0.13\n      output_price: 0.6\n    - name: Qwen/Qwen3-Coder-480B-A35B-Instruct\n      max_input_tokens: 131072\n      input_price: 0.4\n      output_price: 1.6\n      supports_function_calling: true\n    - name: Qwen/Qwen3-Coder-30B-A3B-Instruct\n      max_input_tokens: 262144\n      input_price: 0.07\n      output_price: 0.27\n      supports_function_calling: true\n    - name: Qwen/Qwen3-30B-A3B\n      max_input_tokens: 40960\n      input_price: 0.1\n      output_price: 0.3\n    - name: Qwen/Qwen3-VL-8B-Instruct\n      max_input_tokens: 262144\n      input_price: 0.18\n      output_price: 0.69\n      supports_vision: true\n    - name: deepseek-ai/DeepSeek-V3.2\n      max_input_tokens: 163840\n      input_price: 0.26\n      output_price: 0.39\n      supports_function_calling: true\n    - name: google/gemma-3-27b-it\n      max_input_tokens: 131072\n      input_price: 0.1\n      output_price: 0.2\n    - name: mistralai/Mistral-Small-3.2-24B-Instruct-2506\n      max_input_tokens: 32768\n      input_price: 0.06\n      output_price: 0.12\n    - name: moonshotai/Kimi-K2.5\n      max_input_tokens: 262144\n      input_price: 0.5\n      output_price: 2.8\n      supports_function_calling: true\n    - name: moonshotai/Kimi-K2-Instruct-0905\n      max_input_tokens: 262144\n      input_price: 0.5\n      output_price: 2.0\n      supports_function_calling: true\n    - name: moonshotai/Kimi-K2-Thinking\n      max_input_tokens: 262144\n      input_price: 0.55\n      output_price: 2.5\n      supports_function_calling: true\n    - name: MiniMaxAI/MiniMax-M2.5\n      max_input_tokens: 196608\n      input_price: 0.27\n      output_price: 0.95\n      supports_function_calling: true\n    - name: MiniMaxAI/MiniMax-M2.1\n      max_input_tokens: 196608\n      input_price: 0.27\n      output_price: 0.95\n      supports_function_calling: true\n    - name: zai-org/GLM-5\n      max_input_tokens: 202752\n      input_price: 0.8\n      output_price: 2.56\n      supports_function_calling: true\n    - name: zai-org/GLM-4.7\n      max_input_tokens: 202752\n      input_price: 0.43\n      output_price: 1.75\n      supports_function_calling: true\n    - name: zai-org/GLM-4.7-Flash\n      max_input_tokens: 202752\n      input_price: 0.06\n      output_price: 0.4\n      supports_function_calling: true\n    - name: zai-org/GLM-4.6V\n      max_input_tokens: 131072\n      input_price: 0.3\n      output_price: 0.9\n      supports_vision: true\n    - name: BAAI/bge-large-en-v1.5\n      type: embedding\n      input_price: 0.01\n      max_tokens_per_chunk: 512\n      default_chunk_size: 1000\n      max_batch_size: 100\n    - name: BAAI/bge-m3\n      type: embedding\n      input_price: 0.01\n      max_tokens_per_chunk: 8192\n      default_chunk_size: 2000\n      max_batch_size: 100\n    - name: intfloat/e5-large-v2\n      type: embedding\n      input_price: 0.01\n      max_tokens_per_chunk: 512\n      default_chunk_size: 1000\n      max_batch_size: 100\n    - name: intfloat/multilingual-e5-large\n      type: embedding\n      input_price: 0.01\n      max_tokens_per_chunk: 512\n      default_chunk_size: 1000\n      max_batch_size: 100\n    - name: thenlper/gte-large\n      type: embedding\n      input_price: 0.01\n      max_tokens_per_chunk: 512\n      default_chunk_size: 1000\n      max_batch_size: 100\n\n# Links:\n#  - https://jina.ai/models\n#  - https://api.jina.ai/redoc\n- provider: jina\n  models:\n    - name: jina-embeddings-v3\n      type: embedding\n      input_price: 0\n      max_tokens_per_chunk: 8192\n      default_chunk_size: 2000\n      max_batch_size: 100\n    - name: jina-clip-v2\n      type: embedding\n      input_price: 0\n      max_tokens_per_chunk: 8192\n      default_chunk_size: 1500\n      max_batch_size: 100\n    - name: jina-colbert-v2\n      type: embedding\n      input_price: 0\n      max_tokens_per_chunk: 8192\n      default_chunk_size: 1500\n      max_batch_size: 100\n    - name: jina-reranker-v2-base-multilingual\n      type: reranker\n      max_input_tokens: 8192\n      input_price: 0\n    - name: jina-colbert-v2\n      type: reranker\n      max_input_tokens: 8192\n      input_price: 0\n\n# Links:\n#  - https://docs.voyageai.com/docs/embeddings\n#  - https://docs.voyageai.com/docs/pricing\n#  - https://docs.voyageai.com/reference/\n- provider: voyageai\n  models:\n    - name: voyage-3-large\n      type: embedding\n      max_input_tokens: 120000\n      input_price: 0.18\n      max_tokens_per_chunk: 32000\n      default_chunk_size: 2000\n      max_batch_size: 128\n    - name: voyage-3\n      type: embedding\n      max_input_tokens: 320000\n      input_price: 0.06\n      max_tokens_per_chunk: 32000\n      default_chunk_size: 2000\n      max_batch_size: 128\n    - name: voyage-3-lite\n      type: embedding\n      max_input_tokens: 1000000\n      input_price: 0.02\n      max_tokens_per_chunk: 32000\n      default_chunk_size: 1000\n      max_batch_size: 128\n    - name: rerank-2\n      type: reranker\n      max_input_tokens: 16000\n      input_price: 0.05\n    - name: rerank-2-lite\n      type: reranker\n      max_input_tokens: 8000\n      input_price: 0.02\n"
  },
  {
    "path": "scripts/completions/aichat.bash",
    "content": "_aichat() {\n    local cur prev words cword i opts cmd\n    COMPREPLY=()\n\n    _get_comp_words_by_ref -n : cur prev words cword\n\n    for i in ${words[@]}\n    do\n        case \"${cmd},${i}\" in\n            \",$1\")\n                cmd=\"aichat\"\n                ;;\n            *)\n                ;;\n        esac\n    done\n\n    case \"${cmd}\" in\n        aichat)\n            opts=\"-m -r -s -a -e -c -f -S -h -V --model --prompt --role --session --empty-session --save-session --agent --agent-variable --rag --rebuild-rag --macro --serve --execute --code --file --no-stream --dry-run --info --sync-models --list-models --list-roles --list-sessions --list-agents --list-rags --list-macros --help --version\"\n            if [[ ${cur} == -* || ${cword} -eq 1 ]] ; then\n                COMPREPLY=( $(compgen -W \"${opts}\" -- \"${cur}\") )\n                return 0\n            fi\n\n            case \"${prev}\" in\n                -m|--model)\n                    COMPREPLY=($(compgen -W \"$(\"$1\" --list-models)\" -- \"${cur}\"))\n                    __ltrim_colon_completions \"$cur\"\n                    return 0\n                    ;;\n                --prompt)\n                    COMPREPLY=($(compgen -f \"${cur}\"))\n                    return 0\n                    ;;\n                -r|--role)\n                    COMPREPLY=($(compgen -W \"$(\"$1\" --list-roles)\" -- \"${cur}\"))\n                    __ltrim_colon_completions \"$cur\"\n                    return 0\n                    ;;\n                -s|--session)\n                    COMPREPLY=($(compgen -W \"$(\"$1\" --list-sessions)\" -- \"${cur}\"))\n                    __ltrim_colon_completions \"$cur\"\n                    return 0\n                    ;;\n                -a|--agent)\n                    COMPREPLY=($(compgen -W \"$(\"$1\" --list-agents)\" -- \"${cur}\"))\n                    __ltrim_colon_completions \"$cur\"\n                    return 0\n                    ;;\n                -R|--rag)\n                    COMPREPLY=($(compgen -W \"$(\"$1\" --list-rags)\" -- \"${cur}\"))\n                    __ltrim_colon_completions \"$cur\"\n                    return 0\n                    ;;\n                --macro)\n                    COMPREPLY=($(compgen -W \"$(\"$1\" --list-macros)\" -- \"${cur}\"))\n                    __ltrim_colon_completions \"$cur\"\n                    return 0\n                    ;;\n                -f|--file)\n                    local oldifs\n                    if [[ -v IFS ]]; then\n                        oldifs=\"$IFS\"\n                    fi\n                    IFS=$'\\n'\n                    COMPREPLY=($(compgen -f \"${cur}\"))\n                    if [[ -v oldifs ]]; then\n                        IFS=\"$oldifs\"\n                    fi\n                    if [[ \"${BASH_VERSINFO[0]}\" -ge 4 ]]; then\n                        compopt -o filenames\n                    fi\n                    return 0\n                    ;;\n                *)\n                    COMPREPLY=()\n                    ;;\n            esac\n            COMPREPLY=( $(compgen -W \"${opts}\" -- \"${cur}\") )\n            return 0\n            ;;\n    esac\n}\n\nif [[ \"${BASH_VERSINFO[0]}\" -eq 4 && \"${BASH_VERSINFO[1]}\" -ge 4 || \"${BASH_VERSINFO[0]}\" -gt 4 ]]; then\n    complete -F _aichat -o nosort -o bashdefault -o default aichat\nelse\n    complete -F _aichat -o bashdefault -o default aichat\nfi\n"
  },
  {
    "path": "scripts/completions/aichat.fish",
    "content": "complete -c aichat -s m -l model -x -a \"(aichat --list-models)\" -d 'Select a LLM model' -r\ncomplete -c aichat -l prompt -d 'Use the system prompt'\ncomplete -c aichat -s r -l role -x -a \"(aichat --list-roles)\" -d 'Select a role' -r\ncomplete -c aichat -s s -l session -x  -a \"(aichat --list-sessions)\" -d 'Start or join a session' -r\ncomplete -c aichat -l empty-session -d 'Ensure the session is empty'\ncomplete -c aichat -l save-session -d 'Ensure the new conversation is saved to the session'\ncomplete -c aichat -s a -l agent -x  -a \"(aichat --list-agents)\" -d 'Start a agent' -r\ncomplete -c aichat -l agent-variable -d 'Set agent variables'\ncomplete -c aichat -l rag -x  -a\"(aichat --list-rags)\" -d 'Start a RAG' -r\ncomplete -c aichat -l rebuild-rag -d 'Rebuild the RAG to sync document changes'\ncomplete -c aichat -l macro -x  -a\"(aichat --list-macros)\" -d 'Execute a macro' -r\ncomplete -c aichat -l serve -d 'Serve the LLM API and WebAPP'\ncomplete -c aichat -s e -l execute -d 'Execute commands in natural language'\ncomplete -c aichat -s c -l code -d 'Output code only'\ncomplete -c aichat -s f -l file -d 'Include files, directories, or URLs' -r -F\ncomplete -c aichat -s S -l no-stream -d 'Turn off stream mode'\ncomplete -c aichat -l dry-run -d 'Display the message without sending it'\ncomplete -c aichat -l info -d 'Display information'\ncomplete -c aichat -l sync-models -d 'Sync models updates'\ncomplete -c aichat -l list-models -d 'List all available chat models'\ncomplete -c aichat -l list-roles -d 'List all roles'\ncomplete -c aichat -l list-sessions -d 'List all sessions'\ncomplete -c aichat -l list-agents -d 'List all agents'\ncomplete -c aichat -l list-rags -d 'List all RAGs'\ncomplete -c aichat -l list-macros -d 'List all macros'\ncomplete -c aichat -s h -l help -d 'Print help'\ncomplete -c aichat -s V -l version -d 'Print version'\n"
  },
  {
    "path": "scripts/completions/aichat.nu",
    "content": "module completions {\n\n  def \"nu-complete aichat completions\" [] {\n    [ \"bash\" \"zsh\" \"fish\" \"powershell\" \"nushell\" ]\n  }\n\n  def \"nu-complete aichat model\" [] {\n    ^aichat --list-models |\n    | lines \n    | parse \"{value}\" \n  }\n\n  def \"nu-complete aichat role\" [] {\n    ^aichat --list-roles |\n    | lines \n    | parse \"{value}\" \n  }\n\n  def \"nu-complete aichat session\" [] {\n    ^aichat --list-sessions |\n    | lines \n    | parse \"{value}\" \n  }\n\n  def \"nu-complete aichat agent\" [] {\n    ^aichat --list-agents |\n    | lines \n    | parse \"{value}\" \n  }\n\n  def \"nu-complete aichat rag\" [] {\n    ^aichat --list-rags |\n    | lines \n    | parse \"{value}\" \n  }\n\n  def \"nu-complete aichat macro\" [] {\n    ^aichat --list-macros |\n    | lines \n    | parse \"{value}\" \n  }\n\n  export extern aichat [\n    --model(-m): string@\"nu-complete aichat model\"      # Select a LLM model\n    --prompt                                            # Use the system prompt\n    --role(-r): string@\"nu-complete aichat role\"        # Select a role\n    --session(-s): string@\"nu-complete aichat session\"  # Start or join a session\n    --empty-session                                     # Ensure the session is empty\n    --save-session                                      # Ensure the new conversation is saved to the session\n    --agent(-a): string@\"nu-complete aichat agent\"      # Start a agent\n    --agent-variable                                    # Set agent variables\n    --rag: string@\"nu-complete aichat rag\"              # Start a RAG\n    --rebuild-rag                                       # Rebuild the RAG to sync document changes\n    --macro: string@\"nu-complete aichat macro\"          # Execute a macro\n    --serve                                             # Serve the LLM API and WebAPP\n    --execute(-e)                                       # Execute commands in natural language\n    --code(-c)                                          # Output code only\n    --file(-f): string                                  # Include files, directories, or URLs\n    --no-stream(-S)                                     # Turn off stream mode\n    --dry-run                                           # Display the message without sending it\n    --info                                              # Display information\n    --sync-models                                       # Sync models updates\n    --list-models                                       # List all available chat models\n    --list-roles                                        # List all roles\n    --list-sessions                                     # List all sessions\n    --list-agents                                       # List all agents\n    --list-rags                                         # List all RAGs\n    --list-macros                                       # List all macros\n    ...text: string                                     # Input text\n    --help(-h)                                          # Print help\n    --version(-V)                                       # Print version\n  ]\n\n}\n\nexport use completions *\n"
  },
  {
    "path": "scripts/completions/aichat.ps1",
    "content": "using namespace System.Management.Automation\nusing namespace System.Management.Automation.Language\n\nRegister-ArgumentCompleter -Native -CommandName 'aichat' -ScriptBlock {\n    param($wordToComplete, $commandAst, $cursorPosition)\n\n    $commandElements = $commandAst.CommandElements\n    $command = @(\n        'aichat'\n        for ($i = 1; $i -lt $commandElements.Count; $i++) {\n            $element = $commandElements[$i]\n            if ($element -isnot [StringConstantExpressionAst] -or\n                $element.StringConstantType -ne [StringConstantType]::BareWord -or\n                $element.Value.StartsWith('-') -or\n                $element.Value -eq $wordToComplete) {\n                break\n        }\n        $element.Value\n    }) -join ';'\n\n    $completions = @(switch ($command) {\n        'aichat' {\n            [CompletionResult]::new('-m', '-m', [CompletionResultType]::ParameterName, 'Select a LLM model')\n            [CompletionResult]::new('--model', '--model', [CompletionResultType]::ParameterName, 'Select a LLM model')\n            [CompletionResult]::new('--prompt', '--prompt', [CompletionResultType]::ParameterName, 'Use the system prompt')\n            [CompletionResult]::new('-r', '-r', [CompletionResultType]::ParameterName, 'Select a role')\n            [CompletionResult]::new('--role', '--role', [CompletionResultType]::ParameterName, 'Select a role')\n            [CompletionResult]::new('-s', '-s', [CompletionResultType]::ParameterName, 'Start or join a session')\n            [CompletionResult]::new('--session', '--session', [CompletionResultType]::ParameterName, 'Start or join a session')\n            [CompletionResult]::new('--empty-session', '--empty-session', [CompletionResultType]::ParameterName, 'Ensure the session is empty')\n            [CompletionResult]::new('--save-session', '--save-session', [CompletionResultType]::ParameterName, 'Ensure the new conversation is saved to the session')\n            [CompletionResult]::new('-a', '-a', [CompletionResultType]::ParameterName, 'Start a agent')\n            [CompletionResult]::new('--agent', '--agent', [CompletionResultType]::ParameterName, 'Start a agent')\n            [CompletionResult]::new('--agent-variable', '--agent-variable', [CompletionResultType]::ParameterName, 'Set agent variables')\n            [CompletionResult]::new('--rag', '--rag', [CompletionResultType]::ParameterName, 'Start a RAG')\n            [CompletionResult]::new('--rebuild-rag', '--rebuild-rag', [CompletionResultType]::ParameterName, 'Rebuild the RAG to sync document changes')\n            [CompletionResult]::new('--macro', '--macro', [CompletionResultType]::ParameterName, 'Execute a macro')\n            [CompletionResult]::new('--serve', '--serve', [CompletionResultType]::ParameterName, 'Serve the LLM API and WebAPP')\n            [CompletionResult]::new('-e', '-e', [CompletionResultType]::ParameterName, 'Execute commands in natural language')\n            [CompletionResult]::new('--execute', '--execute', [CompletionResultType]::ParameterName, 'Execute commands in natural language')\n            [CompletionResult]::new('-c', '-c', [CompletionResultType]::ParameterName, 'Output code only')\n            [CompletionResult]::new('--code', '--code', [CompletionResultType]::ParameterName, 'Output code only')\n            [CompletionResult]::new('-f', '-f', [CompletionResultType]::ParameterName, 'Include files, directories, or URLs')\n            [CompletionResult]::new('--file', '--file', [CompletionResultType]::ParameterName, 'Include files, directories, or URLs')\n            [CompletionResult]::new('-S', '-S', [CompletionResultType]::ParameterName, 'Turn off stream mode')\n            [CompletionResult]::new('--no-stream', '--no-stream', [CompletionResultType]::ParameterName, 'Turn off stream mode')\n            [CompletionResult]::new('--dry-run', '--dry-run', [CompletionResultType]::ParameterName, 'Display the message without sending it')\n            [CompletionResult]::new('--info', '--info', [CompletionResultType]::ParameterName, 'Display information')\n            [CompletionResult]::new('--sync-models', '--sync-models', [CompletionResultType]::ParameterName, 'Sync models updates')\n            [CompletionResult]::new('--list-models', '--list-models', [CompletionResultType]::ParameterName, 'List all available chat models')\n            [CompletionResult]::new('--list-roles', '--list-roles', [CompletionResultType]::ParameterName, 'List all roles')\n            [CompletionResult]::new('--list-sessions', '--list-sessions', [CompletionResultType]::ParameterName, 'List all sessions')\n            [CompletionResult]::new('--list-agents', '--list-agents', [CompletionResultType]::ParameterName, 'List all agents')\n            [CompletionResult]::new('--list-rags', '--list-rags', [CompletionResultType]::ParameterName, 'List all RAGs')\n            [CompletionResult]::new('--list-macros', '--list-macros', [CompletionResultType]::ParameterName, 'List all macros')\n            [CompletionResult]::new('-h', '-h', [CompletionResultType]::ParameterName, 'Print help')\n            [CompletionResult]::new('--help', '--help', [CompletionResultType]::ParameterName, 'Print help')\n            [CompletionResult]::new('-V', '-V', [CompletionResultType]::ParameterName, 'Print version')\n            [CompletionResult]::new('--version', '--version', [CompletionResultType]::ParameterName, 'Print version')\n            break\n        }\n    })\n\n    function Get-AichatValues($arg) {\n        $(aichat $arg) -split '\\n' | ForEach-Object { [CompletionResult]::new($_) }\n    }\n\n    if ($commandElements.Count -gt 1) {\n        $offset=2\n        if ($wordToComplete -eq \"\") {\n            $offset=1\n        }\n        $flag = $commandElements[$commandElements.Count-$offset].ToString()\n        dump-args $flag ($flag -eq \"-R\") > /tmp/file1\n        if ($flag -ceq \"-m\" -or $flag -eq \"--model\") {\n            $completions = Get-AichatValues \"--list-models\"\n        } elseif ($flag -ceq \"-r\" -or $flag -eq \"--role\") {\n            $completions = Get-AichatValues \"--list-roles\"\n        } elseif ($flag -ceq \"-s\" -or $flag -eq \"--session\") {\n            $completions = Get-AichatValues \"--list-sessions\"\n        } elseif ($flag -ceq \"-a\" -or $flag -eq \"--agent\") {\n            $completions = Get-AichatValues \"--list-agents\"\n        } elseif ($flag -eq \"--rag\") {\n            $completions = Get-AichatValues \"--list-rags\"\n        } elseif ($flag -eq \"--macro\") {\n            $completions = Get-AichatValues \"--list-macros\"\n        } elseif ($flag -ceq \"-f\" -or $flag -eq \"--file\") {\n            $completions = @()\n        }\n    }\n\n    $completions.Where{ $_.CompletionText -like \"$wordToComplete*\" } |\n        Sort-Object -Property ListItemText\n}\n"
  },
  {
    "path": "scripts/completions/aichat.zsh",
    "content": "#compdef aichat\n\nautoload -U is-at-least\n\n_aichat() {\n    typeset -A opt_args\n    typeset -a _arguments_options\n    local ret=1\n\n    if is-at-least 5.2; then\n        _arguments_options=(-s -S -C)\n    else\n        _arguments_options=(-s -C)\n    fi\n\n    local context curcontext=\"$curcontext\" state line\n    local common=(\n'-m[Select a LLM model]:MODEL:->models' \\\n'--model[Select a LLM model]:MODEL:->models' \\\n'--prompt[Use the system prompt]:PROMPT: ' \\\n'-r[Select a role]:ROLE:->roles' \\\n'--role[Select a role]:ROLE:->roles' \\\n'-s[Start or join a session]:SESSION:->sessions' \\\n'--session[Start or join a session]:SESSION:->sessions' \\\n'--empty-session[Ensure the session is empty]' \\\n'--save-session[Ensure the new conversation is saved to the session]' \\\n'-a[Start a agent]:AGENT:->agents' \\\n'--agent[Start a agent]:AGENT:->agents' \\\n'--agent-variable[Set agent variables]' \\\n'--rag[Start a RAG]:RAG:->rags' \\\n'--rebuild-rag[Rebuild the RAG to sync document changes]' \\\n'--macro[Execute a macro]:MACRO:->macros' \\\n'--serve[Serve the LLM API and WebAPP]' \\\n'-e[Execute commands in natural language]' \\\n'--execute[Execute commands in natural language]' \\\n'-c[Output code only]' \\\n'--code[Output code only]' \\\n'*-f[Include files, directories, or URLs]:FILE:_files' \\\n'*--file[Include files, directories, or URLs]:FILE:_files' \\\n'-S[Turn off stream mode]' \\\n'--no-stream[Turn off stream mode]' \\\n'--dry-run[Display the message without sending it]' \\\n'--info[Display information]' \\\n'--sync-models[Sync models updates]' \\\n'--list-models[List all available chat models]' \\\n'--list-roles[List all roles]' \\\n'--list-sessions[List all sessions]' \\\n'--list-agents[List all agents]' \\\n'--list-rags[List all RAGs]' \\\n'--list-macros[List all macros]' \\\n'-h[Print help]' \\\n'--help[Print help]' \\\n'-V[Print version]' \\\n'--version[Print version]' \\\n'*::text -- Input text:' \\\n    )\n\n\n    _arguments \"${_arguments_options[@]}\" $common \\\n        && ret=0 \n    case $state in\n        models|roles|sessions|agents|rags|macros)\n            local -a values expl\n            values=( ${(f)\"$(_call_program values aichat --list-$state)\"} )\n            _wanted values expl $state compadd -a values && ret=0\n            ;;\n    esac\n    return ret\n}\n\n(( $+functions[_aichat_commands] )) ||\n_aichat_commands() {\n    local commands; commands=()\n    _describe -t commands 'aichat commands' commands \"$@\"\n}\n\nif [ \"$funcstack[1]\" = \"_aichat\" ]; then\n    _aichat \"$@\"\nelse\n    compdef _aichat aichat\nfi\n"
  },
  {
    "path": "scripts/shell-integration/integration.bash",
    "content": "_aichat_bash() {\n    if [[ -n \"$READLINE_LINE\" ]]; then\n        READLINE_LINE=$(aichat -e \"$READLINE_LINE\")\n        READLINE_POINT=${#READLINE_LINE}\n    fi\n}\nbind -x '\"\\ee\": _aichat_bash'"
  },
  {
    "path": "scripts/shell-integration/integration.fish",
    "content": "function _aichat_fish\n    set -l _old (commandline)\n    if test -n $_old\n        echo -n \"⌛\"\n        commandline -f repaint\n        commandline (aichat -e $_old)\n    end\nend\nbind \\ee _aichat_fish"
  },
  {
    "path": "scripts/shell-integration/integration.nu",
    "content": "def _aichat_nushell [] {\n    let _prev = (commandline)\n    if ($_prev != \"\") {\n        print '⌛'\n        commandline edit -r (aichat -e $_prev)\n    }\n}\n\n$env.config.keybindings = ($env.config.keybindings | append {\n        name: aichat_integration\n        modifier: alt\n        keycode: char_e\n        mode: [emacs, vi_insert]\n        event:[\n            {\n                send: executehostcommand,\n                cmd: \"_aichat_nushell\"\n            }\n        ]\n    }\n)"
  },
  {
    "path": "scripts/shell-integration/integration.ps1",
    "content": "Set-PSReadLineKeyHandler -Chord \"alt+e\" -ScriptBlock {\n    $_old = $null\n    [Microsoft.PowerShell.PSConsoleReadline]::GetBufferState([ref]$_old, [ref]$null)\n    if ($_old) {\n        [Microsoft.PowerShell.PSConsoleReadLine]::Insert('⌛')\n        $_new = (aichat -e $_old)\n        [Microsoft.PowerShell.PSConsoleReadLine]::DeleteLine()\n        [Microsoft.PowerShell.PSConsoleReadline]::Insert($_new)\n    }\n}"
  },
  {
    "path": "scripts/shell-integration/integration.zsh",
    "content": "_aichat_zsh() {\n    if [[ -n \"$BUFFER\" ]]; then\n        local _old=$BUFFER\n        BUFFER+=\"⌛\"\n        zle -I && zle redisplay\n        BUFFER=$(aichat -e \"$_old\")\n        zle end-of-line\n    fi\n}\nzle -N _aichat_zsh\nbindkey '\\ee' _aichat_zsh"
  },
  {
    "path": "src/cli.rs",
    "content": "use anyhow::{Context, Result};\nuse clap::Parser;\nuse is_terminal::IsTerminal;\nuse std::io::{stdin, Read};\n\n#[derive(Parser, Debug)]\n#[command(author, version, about, long_about = None)]\npub struct Cli {\n    /// Select a LLM model\n    #[clap(short, long)]\n    pub model: Option<String>,\n    /// Use the system prompt\n    #[clap(long)]\n    pub prompt: Option<String>,\n    /// Select a role\n    #[clap(short, long)]\n    pub role: Option<String>,\n    /// Start or join a session\n    #[clap(short = 's', long)]\n    pub session: Option<Option<String>>,\n    /// Ensure the session is empty\n    #[clap(long)]\n    pub empty_session: bool,\n    /// Ensure the new conversation is saved to the session\n    #[clap(long)]\n    pub save_session: bool,\n    /// Start a agent\n    #[clap(short = 'a', long)]\n    pub agent: Option<String>,\n    /// Set agent variables\n    #[clap(long, value_names = [\"NAME\", \"VALUE\"], num_args = 2)]\n    pub agent_variable: Vec<String>,\n    /// Start a RAG\n    #[clap(long)]\n    pub rag: Option<String>,\n    /// Rebuild the RAG to sync document changes\n    #[clap(long)]\n    pub rebuild_rag: bool,\n    /// Execute a macro\n    #[clap(long = \"macro\", value_name = \"MACRO\")]\n    pub macro_name: Option<String>,\n    /// Serve the LLM API and WebAPP\n    #[clap(long, value_name = \"ADDRESS\")]\n    pub serve: Option<Option<String>>,\n    /// Execute commands in natural language\n    #[clap(short = 'e', long)]\n    pub execute: bool,\n    /// Output code only\n    #[clap(short = 'c', long)]\n    pub code: bool,\n    /// Include files, directories, or URLs\n    #[clap(short = 'f', long, value_name = \"FILE\")]\n    pub file: Vec<String>,\n    /// Turn off stream mode\n    #[clap(short = 'S', long)]\n    pub no_stream: bool,\n    /// Display the message without sending it\n    #[clap(long)]\n    pub dry_run: bool,\n    /// Display information\n    #[clap(long)]\n    pub info: bool,\n    /// Sync models updates\n    #[clap(long)]\n    pub sync_models: bool,\n    /// List all available chat models\n    #[clap(long)]\n    pub list_models: bool,\n    /// List all roles\n    #[clap(long)]\n    pub list_roles: bool,\n    /// List all sessions\n    #[clap(long)]\n    pub list_sessions: bool,\n    /// List all agents\n    #[clap(long)]\n    pub list_agents: bool,\n    /// List all RAGs\n    #[clap(long)]\n    pub list_rags: bool,\n    /// List all macros\n    #[clap(long)]\n    pub list_macros: bool,\n    /// Input text\n    #[clap(trailing_var_arg = true)]\n    text: Vec<String>,\n}\n\nimpl Cli {\n    pub fn text(&self) -> Result<Option<String>> {\n        let mut stdin_text = String::new();\n        if !stdin().is_terminal() {\n            let _ = stdin()\n                .read_to_string(&mut stdin_text)\n                .context(\"Invalid stdin pipe\")?;\n        };\n        match self.text.is_empty() {\n            true => {\n                if stdin_text.is_empty() {\n                    Ok(None)\n                } else {\n                    Ok(Some(stdin_text))\n                }\n            }\n            false => {\n                if self.macro_name.is_some() {\n                    let text = self\n                        .text\n                        .iter()\n                        .map(|v| shell_words::quote(v))\n                        .collect::<Vec<_>>()\n                        .join(\" \");\n                    if stdin_text.is_empty() {\n                        Ok(Some(text))\n                    } else {\n                        Ok(Some(format!(\"{text} -- {stdin_text}\")))\n                    }\n                } else {\n                    let text = self.text.join(\" \");\n                    if stdin_text.is_empty() {\n                        Ok(Some(text))\n                    } else {\n                        Ok(Some(format!(\"{text}\\n{stdin_text}\")))\n                    }\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "src/client/access_token.rs",
    "content": "use anyhow::{anyhow, Result};\nuse chrono::Utc;\nuse indexmap::IndexMap;\nuse parking_lot::RwLock;\nuse std::sync::LazyLock;\n\nstatic ACCESS_TOKENS: LazyLock<RwLock<IndexMap<String, (String, i64)>>> =\n    LazyLock::new(|| RwLock::new(IndexMap::new()));\n\npub fn get_access_token(client_name: &str) -> Result<String> {\n    ACCESS_TOKENS\n        .read()\n        .get(client_name)\n        .map(|(token, _)| token.clone())\n        .ok_or_else(|| anyhow!(\"Invalid access token\"))\n}\n\npub fn is_valid_access_token(client_name: &str) -> bool {\n    let access_tokens = ACCESS_TOKENS.read();\n    let (token, expires_at) = match access_tokens.get(client_name) {\n        Some(v) => v,\n        None => return false,\n    };\n    !token.is_empty() && Utc::now().timestamp() < *expires_at\n}\n\npub fn set_access_token(client_name: &str, token: String, expires_at: i64) {\n    let mut access_tokens = ACCESS_TOKENS.write();\n    let entry = access_tokens.entry(client_name.to_string()).or_default();\n    entry.0 = token;\n    entry.1 = expires_at;\n}\n"
  },
  {
    "path": "src/client/azure_openai.rs",
    "content": "use super::openai::*;\nuse super::*;\n\nuse anyhow::Result;\nuse serde::Deserialize;\n\n#[derive(Debug, Clone, Deserialize)]\npub struct AzureOpenAIConfig {\n    pub name: Option<String>,\n    pub api_base: Option<String>,\n    pub api_key: Option<String>,\n    #[serde(default)]\n    pub models: Vec<ModelData>,\n    pub patch: Option<RequestPatch>,\n    pub extra: Option<ExtraConfig>,\n}\n\nimpl AzureOpenAIClient {\n    config_get_fn!(api_base, get_api_base);\n    config_get_fn!(api_key, get_api_key);\n\n    pub const PROMPTS: [PromptAction<'static>; 2] = [\n        (\n            \"api_base\",\n            \"API Base\",\n            Some(\"e.g. https://{RESOURCE}.openai.azure.com\"),\n        ),\n        (\"api_key\", \"API Key\", None),\n    ];\n}\n\nimpl_client_trait!(\n    AzureOpenAIClient,\n    (\n        prepare_chat_completions,\n        openai_chat_completions,\n        openai_chat_completions_streaming\n    ),\n    (prepare_embeddings, openai_embeddings),\n    (noop_prepare_rerank, noop_rerank),\n);\n\nfn prepare_chat_completions(\n    self_: &AzureOpenAIClient,\n    data: ChatCompletionsData,\n) -> Result<RequestData> {\n    let api_base = self_.get_api_base()?;\n    let api_key = self_.get_api_key()?;\n\n    let url = format!(\n        \"{}/openai/deployments/{}/chat/completions?api-version=2024-12-01-preview\",\n        &api_base,\n        self_.model.real_name()\n    );\n\n    let body = openai_build_chat_completions_body(data, &self_.model);\n\n    let mut request_data = RequestData::new(url, body);\n\n    request_data.header(\"api-key\", api_key);\n\n    Ok(request_data)\n}\n\nfn prepare_embeddings(self_: &AzureOpenAIClient, data: &EmbeddingsData) -> Result<RequestData> {\n    let api_base = self_.get_api_base()?;\n    let api_key = self_.get_api_key()?;\n\n    let url = format!(\n        \"{}/openai/deployments/{}/embeddings?api-version=2024-10-21\",\n        &api_base,\n        self_.model.real_name()\n    );\n\n    let body = openai_build_embeddings_body(data, &self_.model);\n\n    let mut request_data = RequestData::new(url, body);\n\n    request_data.header(\"api-key\", api_key);\n\n    Ok(request_data)\n}\n"
  },
  {
    "path": "src/client/bedrock.rs",
    "content": "use super::*;\n\nuse crate::utils::{base64_decode, encode_uri, hex_encode, hmac_sha256, sha256, strip_think_tag};\n\nuse anyhow::{bail, Context, Result};\nuse aws_smithy_eventstream::frame::{DecodedFrame, MessageFrameDecoder};\nuse aws_smithy_eventstream::smithy::parse_response_headers;\nuse bytes::BytesMut;\nuse chrono::{DateTime, Utc};\nuse futures_util::StreamExt;\nuse indexmap::IndexMap;\nuse reqwest::{Client as ReqwestClient, Method, RequestBuilder};\nuse serde::Deserialize;\nuse serde_json::{json, Value};\n\n#[derive(Debug, Clone, Deserialize)]\npub struct BedrockConfig {\n    pub name: Option<String>,\n    pub access_key_id: Option<String>,\n    pub secret_access_key: Option<String>,\n    pub region: Option<String>,\n    pub session_token: Option<String>,\n    #[serde(default)]\n    pub models: Vec<ModelData>,\n    pub patch: Option<RequestPatch>,\n    pub extra: Option<ExtraConfig>,\n}\n\nimpl BedrockClient {\n    config_get_fn!(access_key_id, get_access_key_id);\n    config_get_fn!(secret_access_key, get_secret_access_key);\n    config_get_fn!(region, get_region);\n    config_get_fn!(session_token, get_session_token);\n\n    pub const PROMPTS: [PromptAction<'static>; 3] = [\n        (\"access_key_id\", \"AWS Access Key ID\", None),\n        (\"secret_access_key\", \"AWS Secret Access Key\", None),\n        (\"region\", \"AWS Region\", None),\n    ];\n\n    fn chat_completions_builder(\n        &self,\n        client: &ReqwestClient,\n        data: ChatCompletionsData,\n    ) -> Result<RequestBuilder> {\n        let access_key_id = self.get_access_key_id()?;\n        let secret_access_key = self.get_secret_access_key()?;\n        let region = self.get_region()?;\n        let session_token = self.get_session_token().ok();\n        let host = format!(\"bedrock-runtime.{region}.amazonaws.com\");\n\n        let model_name = &self.model.real_name();\n\n        let uri = if data.stream {\n            format!(\"/model/{model_name}/converse-stream\")\n        } else {\n            format!(\"/model/{model_name}/converse\")\n        };\n\n        let body = build_chat_completions_body(data, &self.model)?;\n\n        let mut request_data = RequestData::new(\"\", body);\n        self.patch_request_data(&mut request_data);\n        let RequestData {\n            url: _,\n            headers,\n            body,\n        } = request_data;\n\n        let builder = aws_fetch(\n            client,\n            &AwsCredentials {\n                access_key_id,\n                secret_access_key,\n                region,\n                session_token,\n            },\n            AwsRequest {\n                method: Method::POST,\n                host,\n                service: \"bedrock\".into(),\n                uri,\n                querystring: \"\".into(),\n                headers,\n                body: body.to_string(),\n            },\n        )?;\n\n        Ok(builder)\n    }\n\n    fn embeddings_builder(\n        &self,\n        client: &ReqwestClient,\n        data: &EmbeddingsData,\n    ) -> Result<RequestBuilder> {\n        let access_key_id = self.get_access_key_id()?;\n        let secret_access_key = self.get_secret_access_key()?;\n        let region = self.get_region()?;\n        let session_token = self.get_session_token().ok();\n        let host = format!(\"bedrock-runtime.{region}.amazonaws.com\");\n\n        let uri = format!(\"/model/{}/invoke\", self.model.real_name());\n\n        let input_type = match data.query {\n            true => \"search_query\",\n            false => \"search_document\",\n        };\n\n        let body = json!({\n            \"texts\": data.texts,\n            \"input_type\": input_type,\n        });\n\n        let mut request_data = RequestData::new(\"\", body);\n        self.patch_request_data(&mut request_data);\n        let RequestData {\n            url: _,\n            headers,\n            body,\n        } = request_data;\n\n        let builder = aws_fetch(\n            client,\n            &AwsCredentials {\n                access_key_id,\n                secret_access_key,\n                region,\n                session_token,\n            },\n            AwsRequest {\n                method: Method::POST,\n                host,\n                service: \"bedrock\".into(),\n                uri,\n                querystring: \"\".into(),\n                headers,\n                body: body.to_string(),\n            },\n        )?;\n\n        Ok(builder)\n    }\n}\n\n#[async_trait::async_trait]\nimpl Client for BedrockClient {\n    client_common_fns!();\n\n    async fn chat_completions_inner(\n        &self,\n        client: &ReqwestClient,\n        data: ChatCompletionsData,\n    ) -> Result<ChatCompletionsOutput> {\n        let builder = self.chat_completions_builder(client, data)?;\n        chat_completions(builder).await\n    }\n\n    async fn chat_completions_streaming_inner(\n        &self,\n        client: &ReqwestClient,\n        handler: &mut SseHandler,\n        data: ChatCompletionsData,\n    ) -> Result<()> {\n        let builder = self.chat_completions_builder(client, data)?;\n        chat_completions_streaming(builder, handler).await\n    }\n\n    async fn embeddings_inner(\n        &self,\n        client: &ReqwestClient,\n        data: &EmbeddingsData,\n    ) -> Result<EmbeddingsOutput> {\n        let builder = self.embeddings_builder(client, data)?;\n        embeddings(builder).await\n    }\n}\n\nasync fn chat_completions(builder: RequestBuilder) -> Result<ChatCompletionsOutput> {\n    let res = builder.send().await?;\n    let status = res.status();\n    let data: Value = res.json().await?;\n\n    if !status.is_success() {\n        catch_error(&data, status.as_u16())?;\n    }\n\n    debug!(\"non-stream-data: {data}\");\n    extract_chat_completions(&data)\n}\n\nasync fn chat_completions_streaming(\n    builder: RequestBuilder,\n    handler: &mut SseHandler,\n) -> Result<()> {\n    let res = builder.send().await?;\n    let status = res.status();\n    if !status.is_success() {\n        let data: Value = res.json().await?;\n        catch_error(&data, status.as_u16())?;\n        bail!(\"Invalid response data: {data}\");\n    }\n\n    let mut function_name = String::new();\n    let mut function_arguments = String::new();\n    let mut function_id = String::new();\n    let mut reasoning_state = 0;\n\n    let mut stream = res.bytes_stream();\n    let mut buffer = BytesMut::new();\n    let mut decoder = MessageFrameDecoder::new();\n    while let Some(chunk) = stream.next().await {\n        let chunk = chunk?;\n        buffer.extend_from_slice(&chunk);\n        while let DecodedFrame::Complete(message) = decoder.decode_frame(&mut buffer)? {\n            let response_headers = parse_response_headers(&message)?;\n            let message_type = response_headers.message_type.as_str();\n            let smithy_type = response_headers.smithy_type.as_str();\n            match (message_type, smithy_type) {\n                (\"event\", _) => {\n                    let data: Value = serde_json::from_slice(message.payload())?;\n                    debug!(\"stream-data: {smithy_type} {data}\");\n                    match smithy_type {\n                        \"contentBlockStart\" => {\n                            if let Some(tool_use) = data[\"start\"][\"toolUse\"].as_object() {\n                                if let (Some(id), Some(name)) = (\n                                    json_str_from_map(tool_use, \"toolUseId\"),\n                                    json_str_from_map(tool_use, \"name\"),\n                                ) {\n                                    if !function_name.is_empty() {\n                                        if function_arguments.is_empty() {\n                                            function_arguments = String::from(\"{}\");\n                                        }\n                                        let arguments: Value =\n                                        function_arguments.parse().with_context(|| {\n                                            format!(\"Tool call '{function_name}' have non-JSON arguments '{function_arguments}'\")\n                                        })?;\n                                        handler.tool_call(ToolCall::new(\n                                            function_name.clone(),\n                                            arguments,\n                                            Some(function_id.clone()),\n                                        ))?;\n                                    }\n                                    function_arguments.clear();\n                                    function_name = name.into();\n                                    function_id = id.into();\n                                }\n                            }\n                        }\n                        \"contentBlockDelta\" => {\n                            if let Some(text) = data[\"delta\"][\"text\"].as_str() {\n                                handler.text(text)?;\n                            } else if let Some(text) =\n                                data[\"delta\"][\"reasoningContent\"][\"text\"].as_str()\n                            {\n                                if reasoning_state == 0 {\n                                    handler.text(\"<think>\\n\")?;\n                                    reasoning_state = 1;\n                                }\n                                handler.text(text)?;\n                            } else if let Some(input) = data[\"delta\"][\"toolUse\"][\"input\"].as_str() {\n                                function_arguments.push_str(input);\n                            }\n                        }\n                        \"contentBlockStop\" => {\n                            if reasoning_state == 1 {\n                                handler.text(\"\\n</think>\\n\\n\")?;\n                                reasoning_state = 0;\n                            }\n                            if !function_name.is_empty() {\n                                if function_arguments.is_empty() {\n                                    function_arguments = String::from(\"{}\");\n                                }\n                                let arguments: Value = function_arguments.parse().with_context(|| {\n                                    format!(\"Tool call '{function_name}' have non-JSON arguments '{function_arguments}'\")\n                                })?;\n                                handler.tool_call(ToolCall::new(\n                                    function_name.clone(),\n                                    arguments,\n                                    Some(function_id.clone()),\n                                ))?;\n                            }\n                        }\n                        _ => {}\n                    }\n                }\n                (\"exception\", _) => {\n                    let payload = base64_decode(message.payload())?;\n                    let data = String::from_utf8_lossy(&payload);\n\n                    bail!(\"Invalid response data: {data} (smithy_type: {smithy_type})\")\n                }\n                _ => {\n                    bail!(\"Unrecognized message, message_type: {message_type}, smithy_type: {smithy_type}\",);\n                }\n            }\n        }\n    }\n    Ok(())\n}\n\nasync fn embeddings(builder: RequestBuilder) -> Result<EmbeddingsOutput> {\n    let res = builder.send().await?;\n    let status = res.status();\n    let data: Value = res.json().await?;\n\n    if !status.is_success() {\n        catch_error(&data, status.as_u16())?;\n    }\n\n    let res_body: EmbeddingsResBody =\n        serde_json::from_value(data).context(\"Invalid embeddings data\")?;\n    Ok(res_body.embeddings)\n}\n\n#[derive(Deserialize)]\nstruct EmbeddingsResBody {\n    embeddings: Vec<Vec<f32>>,\n}\n\nfn build_chat_completions_body(data: ChatCompletionsData, model: &Model) -> Result<Value> {\n    let ChatCompletionsData {\n        mut messages,\n        temperature,\n        top_p,\n        functions,\n        stream: _,\n    } = data;\n\n    let system_message = extract_system_message(&mut messages);\n\n    let mut network_image_urls = vec![];\n\n    let messages_len = messages.len();\n    let messages: Vec<Value> = messages\n        .into_iter()\n        .enumerate()\n        .flat_map(|(i, message)| {\n            let Message { role, content } = message;\n            match content {\n                MessageContent::Text(text) if role.is_assistant() && i != messages_len - 1 => {\n                    vec![json!({ \"role\": role, \"content\": [ { \"text\": strip_think_tag(&text) } ] })]\n                }\n                MessageContent::Text(text) => vec![json!({\n                    \"role\": role,\n                    \"content\": [\n                        {\n                            \"text\": text,\n                        }\n                    ],\n                })],\n                MessageContent::Array(list) => {\n                    let content: Vec<_> = list\n                        .into_iter()\n                        .map(|item| match item {\n                            MessageContentPart::Text { text } => {\n                                json!({\"text\": text})\n                            }\n                            MessageContentPart::ImageUrl {\n                                image_url: ImageUrl { url },\n                            } => {\n                                if let Some((mime_type, data)) = url\n                                    .strip_prefix(\"data:\")\n                                    .and_then(|v| v.split_once(\";base64,\"))\n                                {\n                                    json!({\n                                        \"image\": {\n                                            \"format\": mime_type.replace(\"image/\", \"\"),\n                                            \"source\": {\n                                                \"bytes\": data,\n                                            }\n                                        }\n                                    })\n                                } else {\n                                    network_image_urls.push(url.clone());\n                                    json!({ \"url\": url })\n                                }\n                            }\n                        })\n                        .collect();\n                    vec![json!({\n                        \"role\": role,\n                        \"content\": content,\n                    })]\n                }\n                MessageContent::ToolCalls(MessageContentToolCalls {\n                    tool_results, text, ..\n                }) => {\n                    let mut assistant_parts = vec![];\n                    let mut user_parts = vec![];\n                    if !text.is_empty() {\n                        assistant_parts.push(json!({\n                            \"text\": text,\n                        }))\n                    }\n                    for tool_result in tool_results {\n                        assistant_parts.push(json!({\n                            \"toolUse\": {\n                                \"toolUseId\": tool_result.call.id,\n                                \"name\": tool_result.call.name,\n                                \"input\": tool_result.call.arguments,\n                            }\n                        }));\n                        user_parts.push(json!({\n                            \"toolResult\": {\n                                \"toolUseId\": tool_result.call.id,\n                                \"content\": [\n                                    {\n                                        \"json\": tool_result.output,\n                                    }\n                                ]\n                            }\n                        }));\n                    }\n                    vec![\n                        json!({\n                            \"role\": \"assistant\",\n                            \"content\": assistant_parts,\n                        }),\n                        json!({\n                            \"role\": \"user\",\n                            \"content\": user_parts,\n                        }),\n                    ]\n                }\n            }\n        })\n        .collect();\n\n    if !network_image_urls.is_empty() {\n        bail!(\n            \"The model does not support network images: {:?}\",\n            network_image_urls\n        );\n    }\n\n    let mut body = json!({\n        \"inferenceConfig\": {},\n        \"messages\": messages,\n    });\n    if let Some(v) = system_message {\n        body[\"system\"] = json!([\n            {\n                \"text\": v,\n            }\n        ])\n    }\n\n    if let Some(v) = model.max_tokens_param() {\n        body[\"inferenceConfig\"][\"maxTokens\"] = v.into();\n    }\n    if let Some(v) = temperature {\n        body[\"inferenceConfig\"][\"temperature\"] = v.into();\n    }\n    if let Some(v) = top_p {\n        body[\"inferenceConfig\"][\"topP\"] = v.into();\n    }\n    if let Some(functions) = functions {\n        let tools: Vec<_> = functions\n            .iter()\n            .map(|v| {\n                json!({\n                    \"toolSpec\": {\n                        \"name\": v.name,\n                        \"description\": v.description,\n                        \"inputSchema\": {\n                            \"json\": v.parameters,\n                        },\n                    }\n                })\n            })\n            .collect();\n        body[\"toolConfig\"] = json!({\n            \"tools\": tools,\n        })\n    }\n    Ok(body)\n}\n\nfn extract_chat_completions(data: &Value) -> Result<ChatCompletionsOutput> {\n    let mut text = String::new();\n    let mut reasoning = None;\n    let mut tool_calls = vec![];\n    if let Some(array) = data[\"output\"][\"message\"][\"content\"].as_array() {\n        for item in array {\n            if let Some(v) = item[\"text\"].as_str() {\n                if !text.is_empty() {\n                    text.push_str(\"\\n\\n\");\n                }\n                text.push_str(v);\n            } else if let Some(reasoning_text) =\n                item[\"reasoningContent\"][\"reasoningText\"].as_object()\n            {\n                if let Some(text) = json_str_from_map(reasoning_text, \"text\") {\n                    reasoning = Some(text.to_string());\n                }\n            } else if let Some(tool_use) = item[\"toolUse\"].as_object() {\n                if let (Some(id), Some(name), Some(input)) = (\n                    json_str_from_map(tool_use, \"toolUseId\"),\n                    json_str_from_map(tool_use, \"name\"),\n                    tool_use.get(\"input\"),\n                ) {\n                    tool_calls.push(ToolCall::new(\n                        name.to_string(),\n                        input.clone(),\n                        Some(id.to_string()),\n                    ))\n                }\n            }\n        }\n    }\n\n    if let Some(reasoning) = reasoning {\n        text = format!(\"<think>\\n{reasoning}\\n</think>\\n\\n{text}\")\n    }\n\n    if text.is_empty() && tool_calls.is_empty() {\n        bail!(\"Invalid response data: {data}\");\n    }\n\n    let output = ChatCompletionsOutput {\n        text,\n        tool_calls,\n        id: None,\n        input_tokens: data[\"usage\"][\"inputTokens\"].as_u64(),\n        output_tokens: data[\"usage\"][\"outputTokens\"].as_u64(),\n    };\n    Ok(output)\n}\n\n#[derive(Debug)]\nstruct AwsCredentials {\n    access_key_id: String,\n    secret_access_key: String,\n    region: String,\n    session_token: Option<String>,\n}\n\n#[derive(Debug)]\nstruct AwsRequest {\n    method: Method,\n    host: String,\n    service: String,\n    uri: String,\n    querystring: String,\n    headers: IndexMap<String, String>,\n    body: String,\n}\n\nfn aws_fetch(\n    client: &ReqwestClient,\n    credentials: &AwsCredentials,\n    request: AwsRequest,\n) -> Result<RequestBuilder> {\n    let AwsRequest {\n        method,\n        host,\n        service,\n        uri,\n        querystring,\n        mut headers,\n        body,\n    } = request;\n    let region = &credentials.region;\n\n    let endpoint = format!(\"https://{host}{uri}\");\n\n    let now: DateTime<Utc> = Utc::now();\n    let amz_date = now.format(\"%Y%m%dT%H%M%SZ\").to_string();\n    let date_stamp = amz_date[0..8].to_string();\n    headers.insert(\"host\".into(), host.clone());\n    headers.insert(\"x-amz-date\".into(), amz_date.clone());\n    if let Some(token) = credentials.session_token.clone() {\n        headers.insert(\"x-amz-security-token\".into(), token);\n    }\n\n    let canonical_headers = headers\n        .iter()\n        .map(|(key, value)| format!(\"{key}:{value}\\n\"))\n        .collect::<Vec<_>>()\n        .join(\"\");\n\n    let signed_headers = headers\n        .iter()\n        .map(|(key, _)| key.as_str())\n        .collect::<Vec<_>>()\n        .join(\";\");\n\n    let payload_hash = sha256(&body);\n\n    let canonical_request = format!(\n        \"{}\\n{}\\n{}\\n{}\\n{}\\n{}\",\n        method,\n        encode_uri(&uri),\n        querystring,\n        canonical_headers,\n        signed_headers,\n        payload_hash\n    );\n\n    let algorithm = \"AWS4-HMAC-SHA256\";\n    let credential_scope = format!(\"{date_stamp}/{region}/{service}/aws4_request\");\n    let string_to_sign = format!(\n        \"{}\\n{}\\n{}\\n{}\",\n        algorithm,\n        amz_date,\n        credential_scope,\n        sha256(&canonical_request)\n    );\n\n    let signing_key = gen_signing_key(\n        &credentials.secret_access_key,\n        &date_stamp,\n        region,\n        &service,\n    );\n    let signature = hmac_sha256(&signing_key, &string_to_sign);\n    let signature = hex_encode(&signature);\n\n    let authorization_header = format!(\n        \"{} Credential={}/{}, SignedHeaders={}, Signature={}\",\n        algorithm, credentials.access_key_id, credential_scope, signed_headers, signature\n    );\n\n    headers.insert(\"authorization\".into(), authorization_header);\n\n    debug!(\"Request {endpoint} {body}\");\n\n    let mut request_builder = client.request(method, endpoint).body(body);\n\n    for (key, value) in &headers {\n        request_builder = request_builder.header(key, value);\n    }\n\n    Ok(request_builder)\n}\n\nfn gen_signing_key(key: &str, date_stamp: &str, region: &str, service: &str) -> Vec<u8> {\n    let k_date = hmac_sha256(format!(\"AWS4{key}\").as_bytes(), date_stamp);\n    let k_region = hmac_sha256(&k_date, region);\n    let k_service = hmac_sha256(&k_region, service);\n    hmac_sha256(&k_service, \"aws4_request\")\n}\n"
  },
  {
    "path": "src/client/claude.rs",
    "content": "use super::*;\n\nuse crate::utils::strip_think_tag;\n\nuse anyhow::{bail, Context, Result};\nuse reqwest::RequestBuilder;\nuse serde::Deserialize;\nuse serde_json::{json, Value};\n\nconst API_BASE: &str = \"https://api.anthropic.com/v1\";\n\n#[derive(Debug, Clone, Deserialize)]\npub struct ClaudeConfig {\n    pub name: Option<String>,\n    pub api_key: Option<String>,\n    pub api_base: Option<String>,\n    #[serde(default)]\n    pub models: Vec<ModelData>,\n    pub patch: Option<RequestPatch>,\n    pub extra: Option<ExtraConfig>,\n}\n\nimpl ClaudeClient {\n    config_get_fn!(api_key, get_api_key);\n    config_get_fn!(api_base, get_api_base);\n\n    pub const PROMPTS: [PromptAction<'static>; 1] = [(\"api_key\", \"API Key\", None)];\n}\n\nimpl_client_trait!(\n    ClaudeClient,\n    (\n        prepare_chat_completions,\n        claude_chat_completions,\n        claude_chat_completions_streaming\n    ),\n    (noop_prepare_embeddings, noop_embeddings),\n    (noop_prepare_rerank, noop_rerank),\n);\n\nfn prepare_chat_completions(\n    self_: &ClaudeClient,\n    data: ChatCompletionsData,\n) -> Result<RequestData> {\n    let api_key = self_.get_api_key()?;\n    let api_base = self_\n        .get_api_base()\n        .unwrap_or_else(|_| API_BASE.to_string());\n\n    let url = format!(\"{}/messages\", api_base.trim_end_matches('/'));\n    let body = claude_build_chat_completions_body(data, &self_.model)?;\n\n    let mut request_data = RequestData::new(url, body);\n\n    request_data.header(\"anthropic-version\", \"2023-06-01\");\n    request_data.header(\"x-api-key\", api_key);\n\n    Ok(request_data)\n}\n\npub async fn claude_chat_completions(\n    builder: RequestBuilder,\n    _model: &Model,\n) -> Result<ChatCompletionsOutput> {\n    let res = builder.send().await?;\n    let status = res.status();\n    let data: Value = res.json().await?;\n    if !status.is_success() {\n        catch_error(&data, status.as_u16())?;\n    }\n    debug!(\"non-stream-data: {data}\");\n    claude_extract_chat_completions(&data)\n}\n\npub async fn claude_chat_completions_streaming(\n    builder: RequestBuilder,\n    handler: &mut SseHandler,\n    _model: &Model,\n) -> Result<()> {\n    let mut function_name = String::new();\n    let mut function_arguments = String::new();\n    let mut function_id = String::new();\n    let mut reasoning_state = 0;\n    let handle = |message: SseMmessage| -> Result<bool> {\n        let data: Value = serde_json::from_str(&message.data)?;\n        debug!(\"stream-data: {data}\");\n        if let Some(typ) = data[\"type\"].as_str() {\n            match typ {\n                \"content_block_start\" => {\n                    if let (Some(\"tool_use\"), Some(name), Some(id)) = (\n                        data[\"content_block\"][\"type\"].as_str(),\n                        data[\"content_block\"][\"name\"].as_str(),\n                        data[\"content_block\"][\"id\"].as_str(),\n                    ) {\n                        if !function_name.is_empty() {\n                            let arguments: Value =\n                                function_arguments.parse().with_context(|| {\n                                    format!(\"Tool call '{function_name}' have non-JSON arguments '{function_arguments}'\")\n                                })?;\n                            handler.tool_call(ToolCall::new(\n                                function_name.clone(),\n                                arguments,\n                                Some(function_id.clone()),\n                            ))?;\n                        }\n                        function_name = name.into();\n                        function_arguments.clear();\n                        function_id = id.into();\n                    }\n                }\n                \"content_block_delta\" => {\n                    if let Some(text) = data[\"delta\"][\"text\"].as_str() {\n                        handler.text(text)?;\n                    } else if let Some(text) = data[\"delta\"][\"thinking\"].as_str() {\n                        if reasoning_state == 0 {\n                            handler.text(\"<think>\\n\")?;\n                            reasoning_state = 1;\n                        }\n                        handler.text(text)?;\n                    } else if let (true, Some(partial_json)) = (\n                        !function_name.is_empty(),\n                        data[\"delta\"][\"partial_json\"].as_str(),\n                    ) {\n                        function_arguments.push_str(partial_json);\n                    }\n                }\n                \"content_block_stop\" => {\n                    if reasoning_state == 1 {\n                        handler.text(\"\\n</think>\\n\\n\")?;\n                        reasoning_state = 0;\n                    }\n                    if !function_name.is_empty() {\n                        let arguments: Value = if function_arguments.is_empty() {\n                            json!({})\n                        } else {\n                            function_arguments.parse().with_context(|| {\n                                format!(\"Tool call '{function_name}' have non-JSON arguments '{function_arguments}'\")\n                            })?\n                        };\n                        handler.tool_call(ToolCall::new(\n                            function_name.clone(),\n                            arguments,\n                            Some(function_id.clone()),\n                        ))?;\n                    }\n                }\n                _ => {}\n            }\n        }\n        Ok(false)\n    };\n\n    sse_stream(builder, handle).await\n}\n\npub fn claude_build_chat_completions_body(\n    data: ChatCompletionsData,\n    model: &Model,\n) -> Result<Value> {\n    let ChatCompletionsData {\n        mut messages,\n        temperature,\n        top_p,\n        functions,\n        stream,\n    } = data;\n\n    let system_message = extract_system_message(&mut messages);\n\n    let mut network_image_urls = vec![];\n\n    let messages_len = messages.len();\n    let messages: Vec<Value> = messages\n        .into_iter()\n        .enumerate()\n        .flat_map(|(i, message)| {\n            let Message { role, content } = message;\n            match content {\n                MessageContent::Text(text) if role.is_assistant() && i != messages_len - 1 => {\n                    vec![json!({ \"role\": role, \"content\": strip_think_tag(&text) })]\n                }\n                MessageContent::Text(text) => vec![json!({\n                    \"role\": role,\n                    \"content\": text,\n                })],\n                MessageContent::Array(list) => {\n                    let content: Vec<_> = list\n                        .into_iter()\n                        .map(|item| match item {\n                            MessageContentPart::Text { text } => {\n                                json!({\"type\": \"text\", \"text\": text})\n                            }\n                            MessageContentPart::ImageUrl {\n                                image_url: ImageUrl { url },\n                            } => {\n                                if let Some((mime_type, data)) = url\n                                    .strip_prefix(\"data:\")\n                                    .and_then(|v| v.split_once(\";base64,\"))\n                                {\n                                    json!({\n                                        \"type\": \"image\",\n                                        \"source\": {\n                                            \"type\": \"base64\",\n                                            \"media_type\": mime_type,\n                                            \"data\": data,\n                                        }\n                                    })\n                                } else {\n                                    network_image_urls.push(url.clone());\n                                    json!({ \"url\": url })\n                                }\n                            }\n                        })\n                        .collect();\n                    vec![json!({\n                        \"role\": role,\n                        \"content\": content,\n                    })]\n                }\n                MessageContent::ToolCalls(MessageContentToolCalls {\n                    tool_results, text, ..\n                }) => {\n                    let mut assistant_parts = vec![];\n                    let mut user_parts = vec![];\n                    if !text.is_empty() {\n                        assistant_parts.push(json!({\n                            \"type\": \"text\",\n                            \"text\": text,\n                        }))\n                    }\n                    for tool_result in tool_results {\n                        assistant_parts.push(json!({\n                            \"type\": \"tool_use\",\n                            \"id\": tool_result.call.id,\n                            \"name\": tool_result.call.name,\n                            \"input\": tool_result.call.arguments,\n                        }));\n                        user_parts.push(json!({\n                            \"type\": \"tool_result\",\n                            \"tool_use_id\": tool_result.call.id,\n                            \"content\": tool_result.output.to_string(),\n                        }));\n                    }\n                    vec![\n                        json!({\n                            \"role\": \"assistant\",\n                            \"content\": assistant_parts,\n                        }),\n                        json!({\n                            \"role\": \"user\",\n                            \"content\": user_parts,\n                        }),\n                    ]\n                }\n            }\n        })\n        .collect();\n\n    if !network_image_urls.is_empty() {\n        bail!(\n            \"The model does not support network images: {:?}\",\n            network_image_urls\n        );\n    }\n\n    let mut body = json!({\n        \"model\": model.real_name(),\n        \"messages\": messages,\n    });\n    if let Some(v) = system_message {\n        body[\"system\"] = v.into();\n    }\n    if let Some(v) = model.max_tokens_param() {\n        body[\"max_tokens\"] = v.into();\n    }\n    if let Some(v) = temperature {\n        body[\"temperature\"] = v.into();\n    }\n    if let Some(v) = top_p {\n        body[\"top_p\"] = v.into();\n    }\n    if stream {\n        body[\"stream\"] = true.into();\n    }\n    if let Some(functions) = functions {\n        body[\"tools\"] = functions\n            .iter()\n            .map(|v| {\n                json!({\n                    \"name\": v.name,\n                    \"description\": v.description,\n                    \"input_schema\": v.parameters,\n                })\n            })\n            .collect();\n    }\n    Ok(body)\n}\n\npub fn claude_extract_chat_completions(data: &Value) -> Result<ChatCompletionsOutput> {\n    let mut text = String::new();\n    let mut reasoning = None;\n    let mut tool_calls = vec![];\n    if let Some(list) = data[\"content\"].as_array() {\n        for item in list {\n            match item[\"type\"].as_str() {\n                Some(\"thinking\") => {\n                    if let Some(v) = item[\"thinking\"].as_str() {\n                        reasoning = Some(v.to_string());\n                    }\n                }\n                Some(\"text\") => {\n                    if let Some(v) = item[\"text\"].as_str() {\n                        if !text.is_empty() {\n                            text.push_str(\"\\n\\n\");\n                        }\n                        text.push_str(v);\n                    }\n                }\n                Some(\"tool_use\") => {\n                    if let (Some(name), Some(input), Some(id)) = (\n                        item[\"name\"].as_str(),\n                        item.get(\"input\"),\n                        item[\"id\"].as_str(),\n                    ) {\n                        tool_calls.push(ToolCall::new(\n                            name.to_string(),\n                            input.clone(),\n                            Some(id.to_string()),\n                        ));\n                    }\n                }\n                _ => {}\n            }\n        }\n    }\n    if let Some(reasoning) = reasoning {\n        text = format!(\"<think>\\n{reasoning}\\n</think>\\n\\n{text}\")\n    }\n\n    if text.is_empty() && tool_calls.is_empty() {\n        bail!(\"Invalid response data: {data}\");\n    }\n\n    let output = ChatCompletionsOutput {\n        text: text.to_string(),\n        tool_calls,\n        id: data[\"id\"].as_str().map(|v| v.to_string()),\n        input_tokens: data[\"usage\"][\"input_tokens\"].as_u64(),\n        output_tokens: data[\"usage\"][\"output_tokens\"].as_u64(),\n    };\n    Ok(output)\n}\n"
  },
  {
    "path": "src/client/cohere.rs",
    "content": "use super::openai::*;\nuse super::openai_compatible::*;\nuse super::*;\n\nuse anyhow::{bail, Context, Result};\nuse reqwest::RequestBuilder;\nuse serde::Deserialize;\nuse serde_json::{json, Value};\n\nconst API_BASE: &str = \"https://api.cohere.ai/v2\";\n\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct CohereConfig {\n    pub name: Option<String>,\n    pub api_key: Option<String>,\n    pub api_base: Option<String>,\n    #[serde(default)]\n    pub models: Vec<ModelData>,\n    pub patch: Option<RequestPatch>,\n    pub extra: Option<ExtraConfig>,\n}\n\nimpl CohereClient {\n    config_get_fn!(api_key, get_api_key);\n    config_get_fn!(api_base, get_api_base);\n\n    pub const PROMPTS: [PromptAction<'static>; 1] = [(\"api_key\", \"API Key\", None)];\n}\n\nimpl_client_trait!(\n    CohereClient,\n    (\n        prepare_chat_completions,\n        chat_completions,\n        chat_completions_streaming\n    ),\n    (prepare_embeddings, embeddings),\n    (prepare_rerank, generic_rerank),\n);\n\nfn prepare_chat_completions(\n    self_: &CohereClient,\n    data: ChatCompletionsData,\n) -> Result<RequestData> {\n    let api_key = self_.get_api_key()?;\n    let api_base = self_\n        .get_api_base()\n        .unwrap_or_else(|_| API_BASE.to_string());\n\n    let url = format!(\"{}/chat\", api_base.trim_end_matches('/'));\n    let mut body = openai_build_chat_completions_body(data, &self_.model);\n    if let Some(obj) = body.as_object_mut() {\n        if let Some(top_p) = obj.remove(\"top_p\") {\n            obj.insert(\"p\".to_string(), top_p);\n        }\n    }\n\n    let mut request_data = RequestData::new(url, body);\n\n    request_data.bearer_auth(api_key);\n\n    Ok(request_data)\n}\n\nfn prepare_embeddings(self_: &CohereClient, data: &EmbeddingsData) -> Result<RequestData> {\n    let api_key = self_.get_api_key()?;\n    let api_base = self_\n        .get_api_base()\n        .unwrap_or_else(|_| API_BASE.to_string());\n\n    let url = format!(\"{}/embed\", api_base.trim_end_matches('/'));\n\n    let input_type = match data.query {\n        true => \"search_query\",\n        false => \"search_document\",\n    };\n\n    let body = json!({\n        \"model\": self_.model.real_name(),\n        \"texts\": data.texts,\n        \"input_type\": input_type,\n        \"embedding_types\": [\"float\"],\n    });\n\n    let mut request_data = RequestData::new(url, body);\n\n    request_data.bearer_auth(api_key);\n\n    Ok(request_data)\n}\n\nfn prepare_rerank(self_: &CohereClient, data: &RerankData) -> Result<RequestData> {\n    let api_key = self_.get_api_key()?;\n    let api_base = self_\n        .get_api_base()\n        .unwrap_or_else(|_| API_BASE.to_string());\n\n    let url = format!(\"{}/rerank\", api_base.trim_end_matches('/'));\n    let body = generic_build_rerank_body(data, &self_.model);\n\n    let mut request_data = RequestData::new(url, body);\n\n    request_data.bearer_auth(api_key);\n\n    Ok(request_data)\n}\n\nasync fn chat_completions(\n    builder: RequestBuilder,\n    _model: &Model,\n) -> Result<ChatCompletionsOutput> {\n    let res = builder.send().await?;\n    let status = res.status();\n    let data: Value = res.json().await?;\n    if !status.is_success() {\n        catch_error(&data, status.as_u16())?;\n    }\n\n    debug!(\"non-stream-data: {data}\");\n    extract_chat_completions(&data)\n}\n\nasync fn chat_completions_streaming(\n    builder: RequestBuilder,\n    handler: &mut SseHandler,\n    _model: &Model,\n) -> Result<()> {\n    let mut function_name = String::new();\n    let mut function_arguments = String::new();\n    let mut function_id = String::new();\n    let handle = |message: SseMmessage| -> Result<bool> {\n        if message.data == \"[DONE]\" {\n            return Ok(true);\n        }\n        let data: Value = serde_json::from_str(&message.data)?;\n        debug!(\"stream-data: {data}\");\n        if let Some(typ) = data[\"type\"].as_str() {\n            match typ {\n                \"content-delta\" => {\n                    if let Some(text) = data[\"delta\"][\"message\"][\"content\"][\"text\"].as_str() {\n                        handler.text(text)?;\n                    }\n                }\n                \"tool-plan-delta\" => {\n                    if let Some(text) = data[\"delta\"][\"message\"][\"tool_plan\"].as_str() {\n                        handler.text(text)?;\n                    }\n                }\n                \"tool-call-start\" => {\n                    if let (Some(function), Some(id)) = (\n                        data[\"delta\"][\"message\"][\"tool_calls\"][\"function\"].as_object(),\n                        data[\"delta\"][\"message\"][\"tool_calls\"][\"id\"].as_str(),\n                    ) {\n                        if let Some(name) = function.get(\"name\").and_then(|v| v.as_str()) {\n                            function_name = name.to_string();\n                        }\n                        function_id = id.to_string();\n                    }\n                }\n                \"tool-call-delta\" => {\n                    if let Some(text) =\n                        data[\"delta\"][\"message\"][\"tool_calls\"][\"function\"][\"arguments\"].as_str()\n                    {\n                        function_arguments.push_str(text);\n                    }\n                }\n                \"tool-call-end\" => {\n                    if !function_name.is_empty() {\n                        let arguments: Value = function_arguments.parse().with_context(|| {\n                            format!(\"Tool call '{function_name}' have non-JSON arguments '{function_arguments}'\")\n                        })?;\n                        handler.tool_call(ToolCall::new(\n                            function_name.clone(),\n                            arguments,\n                            Some(function_id.clone()),\n                        ))?;\n                    }\n                    function_name.clear();\n                    function_arguments.clear();\n                    function_id.clear();\n                }\n                _ => {}\n            }\n        }\n        Ok(false)\n    };\n\n    sse_stream(builder, handle).await\n}\n\nasync fn embeddings(builder: RequestBuilder, _model: &Model) -> Result<EmbeddingsOutput> {\n    let res = builder.send().await?;\n    let status = res.status();\n    let data: Value = res.json().await?;\n    if !status.is_success() {\n        catch_error(&data, status.as_u16())?;\n    }\n    let res_body: EmbeddingsResBody =\n        serde_json::from_value(data).context(\"Invalid embeddings data\")?;\n    Ok(res_body.embeddings.float)\n}\n\n#[derive(Deserialize)]\nstruct EmbeddingsResBody {\n    embeddings: EmbeddingsResBodyEmbeddings,\n}\n\n#[derive(Deserialize)]\nstruct EmbeddingsResBodyEmbeddings {\n    float: Vec<Vec<f32>>,\n}\n\nfn extract_chat_completions(data: &Value) -> Result<ChatCompletionsOutput> {\n    let mut text = data[\"message\"][\"content\"][0][\"text\"]\n        .as_str()\n        .unwrap_or_default()\n        .to_string();\n\n    let mut tool_calls = vec![];\n    if let Some(calls) = data[\"message\"][\"tool_calls\"].as_array() {\n        if text.is_empty() {\n            if let Some(tool_plain) = data[\"message\"][\"tool_plan\"].as_str() {\n                text = tool_plain.to_string();\n            }\n        }\n        for call in calls {\n            if let (Some(name), Some(arguments), Some(id)) = (\n                call[\"function\"][\"name\"].as_str(),\n                call[\"function\"][\"arguments\"].as_str(),\n                call[\"id\"].as_str(),\n            ) {\n                let arguments: Value = arguments.parse().with_context(|| {\n                    format!(\"Tool call '{name}' have non-JSON arguments '{arguments}'\")\n                })?;\n                tool_calls.push(ToolCall::new(\n                    name.to_string(),\n                    arguments,\n                    Some(id.to_string()),\n                ));\n            }\n        }\n    }\n\n    if text.is_empty() && tool_calls.is_empty() {\n        bail!(\"Invalid response data: {data}\");\n    }\n    let output = ChatCompletionsOutput {\n        text,\n        tool_calls,\n        id: data[\"id\"].as_str().map(|v| v.to_string()),\n        input_tokens: data[\"usage\"][\"billed_units\"][\"input_tokens\"].as_u64(),\n        output_tokens: data[\"usage\"][\"billed_units\"][\"output_tokens\"].as_u64(),\n    };\n    Ok(output)\n}\n"
  },
  {
    "path": "src/client/common.rs",
    "content": "use super::*;\n\nuse crate::{\n    config::{Config, GlobalConfig, Input},\n    function::{eval_tool_calls, FunctionDeclaration, ToolCall, ToolResult},\n    render::render_stream,\n    utils::*,\n};\n\nuse anyhow::{bail, Context, Result};\nuse fancy_regex::Regex;\nuse indexmap::IndexMap;\nuse inquire::{\n    list_option::ListOption, required, validator::Validation, MultiSelect, Select, Text,\n};\nuse reqwest::{Client as ReqwestClient, RequestBuilder};\nuse serde::Deserialize;\nuse serde_json::{json, Value};\nuse std::sync::LazyLock;\nuse std::time::Duration;\nuse tokio::sync::mpsc::unbounded_channel;\n\nconst MODELS_YAML: &str = include_str!(\"../../models.yaml\");\n\npub static ALL_PROVIDER_MODELS: LazyLock<Vec<ProviderModels>> = LazyLock::new(|| {\n    Config::loal_models_override()\n        .ok()\n        .unwrap_or_else(|| serde_yaml::from_str(MODELS_YAML).unwrap())\n});\n\nstatic EMBEDDING_MODEL_RE: LazyLock<Regex> = LazyLock::new(|| {\n    Regex::new(r\"((^|/)(bge-|e5-|uae-|gte-|text-)|embed|multilingual|minilm)\").unwrap()\n});\n\nstatic ESCAPE_SLASH_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r\"(?<!\\\\)/\").unwrap());\n\n#[async_trait::async_trait]\npub trait Client: Sync + Send {\n    fn global_config(&self) -> &GlobalConfig;\n\n    fn extra_config(&self) -> Option<&ExtraConfig>;\n\n    fn patch_config(&self) -> Option<&RequestPatch>;\n\n    fn name(&self) -> &str;\n\n    fn model(&self) -> &Model;\n\n    fn model_mut(&mut self) -> &mut Model;\n\n    fn build_client(&self) -> Result<ReqwestClient> {\n        let mut builder = ReqwestClient::builder();\n        let extra = self.extra_config();\n        let timeout = extra.and_then(|v| v.connect_timeout).unwrap_or(10);\n        if let Some(proxy) = extra.and_then(|v| v.proxy.as_deref()) {\n            builder = set_proxy(builder, proxy)?;\n        }\n        if let Some(user_agent) = self.global_config().read().user_agent.as_ref() {\n            builder = builder.user_agent(user_agent);\n        }\n        let client = builder\n            .connect_timeout(Duration::from_secs(timeout))\n            .build()\n            .with_context(|| \"Failed to build client\")?;\n        Ok(client)\n    }\n\n    async fn chat_completions(&self, input: Input) -> Result<ChatCompletionsOutput> {\n        if self.global_config().read().dry_run {\n            let content = input.echo_messages();\n            return Ok(ChatCompletionsOutput::new(&content));\n        }\n        let client = self.build_client()?;\n        let data = input.prepare_completion_data(self.model(), false)?;\n        self.chat_completions_inner(&client, data)\n            .await\n            .with_context(|| \"Failed to call chat-completions api\")\n    }\n\n    async fn chat_completions_streaming(\n        &self,\n        input: &Input,\n        handler: &mut SseHandler,\n    ) -> Result<()> {\n        let abort_signal = handler.abort();\n        let input = input.clone();\n        tokio::select! {\n            ret = async {\n                if self.global_config().read().dry_run {\n                    let content = input.echo_messages();\n                    handler.text(&content)?;\n                    return Ok(());\n                }\n                let client = self.build_client()?;\n                let data = input.prepare_completion_data(self.model(), true)?;\n                self.chat_completions_streaming_inner(&client, handler, data).await\n            } => {\n                handler.done();\n                ret.with_context(|| \"Failed to call chat-completions api\")\n            }\n            _ = wait_abort_signal(&abort_signal) => {\n                handler.done();\n                Ok(())\n            },\n        }\n    }\n\n    async fn embeddings(&self, data: &EmbeddingsData) -> Result<Vec<Vec<f32>>> {\n        let client = self.build_client()?;\n        self.embeddings_inner(&client, data)\n            .await\n            .context(\"Failed to call embeddings api\")\n    }\n\n    async fn rerank(&self, data: &RerankData) -> Result<RerankOutput> {\n        let client = self.build_client()?;\n        self.rerank_inner(&client, data)\n            .await\n            .context(\"Failed to call rerank api\")\n    }\n\n    async fn chat_completions_inner(\n        &self,\n        client: &ReqwestClient,\n        data: ChatCompletionsData,\n    ) -> Result<ChatCompletionsOutput>;\n\n    async fn chat_completions_streaming_inner(\n        &self,\n        client: &ReqwestClient,\n        handler: &mut SseHandler,\n        data: ChatCompletionsData,\n    ) -> Result<()>;\n\n    async fn embeddings_inner(\n        &self,\n        _client: &ReqwestClient,\n        _data: &EmbeddingsData,\n    ) -> Result<EmbeddingsOutput> {\n        bail!(\"The client doesn't support embeddings api\")\n    }\n\n    async fn rerank_inner(\n        &self,\n        _client: &ReqwestClient,\n        _data: &RerankData,\n    ) -> Result<RerankOutput> {\n        bail!(\"The client doesn't support rerank api\")\n    }\n\n    fn request_builder(\n        &self,\n        client: &reqwest::Client,\n        mut request_data: RequestData,\n    ) -> RequestBuilder {\n        self.patch_request_data(&mut request_data);\n        request_data.into_builder(client)\n    }\n\n    fn patch_request_data(&self, request_data: &mut RequestData) {\n        let model_type = self.model().model_type();\n        if let Some(patch) = self.model().patch() {\n            request_data.apply_patch(patch.clone());\n        }\n\n        let patch_map = std::env::var(get_env_name(&format!(\n            \"patch_{}_{}\",\n            self.model().client_name(),\n            model_type.api_name(),\n        )))\n        .ok()\n        .and_then(|v| serde_json::from_str(&v).ok())\n        .or_else(|| {\n            self.patch_config()\n                .and_then(|v| model_type.extract_patch(v))\n                .cloned()\n        });\n        let patch_map = match patch_map {\n            Some(v) => v,\n            _ => return,\n        };\n        for (key, patch) in patch_map {\n            let key = ESCAPE_SLASH_RE.replace_all(&key, r\"\\/\");\n            if let Ok(regex) = Regex::new(&format!(\"^({key})$\")) {\n                if let Ok(true) = regex.is_match(self.model().name()) {\n                    request_data.apply_patch(patch);\n                    return;\n                }\n            }\n        }\n    }\n}\n\nimpl Default for ClientConfig {\n    fn default() -> Self {\n        Self::OpenAIConfig(OpenAIConfig::default())\n    }\n}\n\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct ExtraConfig {\n    pub proxy: Option<String>,\n    pub connect_timeout: Option<u64>,\n}\n\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct RequestPatch {\n    pub chat_completions: Option<ApiPatch>,\n    pub embeddings: Option<ApiPatch>,\n    pub rerank: Option<ApiPatch>,\n}\n\npub type ApiPatch = IndexMap<String, Value>;\n\npub struct RequestData {\n    pub url: String,\n    pub headers: IndexMap<String, String>,\n    pub body: Value,\n}\n\nimpl RequestData {\n    pub fn new<T>(url: T, body: Value) -> Self\n    where\n        T: std::fmt::Display,\n    {\n        Self {\n            url: url.to_string(),\n            headers: Default::default(),\n            body,\n        }\n    }\n\n    pub fn bearer_auth<T>(&mut self, auth: T)\n    where\n        T: std::fmt::Display,\n    {\n        self.headers\n            .insert(\"authorization\".into(), format!(\"Bearer {auth}\"));\n    }\n\n    pub fn header<K, V>(&mut self, key: K, value: V)\n    where\n        K: std::fmt::Display,\n        V: std::fmt::Display,\n    {\n        self.headers.insert(key.to_string(), value.to_string());\n    }\n\n    pub fn into_builder(self, client: &ReqwestClient) -> RequestBuilder {\n        let RequestData { url, headers, body } = self;\n        debug!(\"Request {url} {body}\");\n\n        let mut builder = client.post(url);\n        for (key, value) in headers {\n            builder = builder.header(key, value);\n        }\n        builder = builder.json(&body);\n        builder\n    }\n\n    pub fn apply_patch(&mut self, patch: Value) {\n        if let Some(patch_url) = patch[\"url\"].as_str() {\n            self.url = patch_url.into();\n        }\n        if let Some(patch_body) = patch.get(\"body\") {\n            json_patch::merge(&mut self.body, patch_body)\n        }\n        if let Some(patch_headers) = patch[\"headers\"].as_object() {\n            for (key, value) in patch_headers {\n                if let Some(value) = value.as_str() {\n                    self.header(key, value)\n                } else if value.is_null() {\n                    self.headers.swap_remove(key);\n                }\n            }\n        }\n    }\n}\n\n#[derive(Debug)]\npub struct ChatCompletionsData {\n    pub messages: Vec<Message>,\n    pub temperature: Option<f64>,\n    pub top_p: Option<f64>,\n    pub functions: Option<Vec<FunctionDeclaration>>,\n    pub stream: bool,\n}\n\n#[derive(Debug, Clone, Default)]\npub struct ChatCompletionsOutput {\n    pub text: String,\n    pub tool_calls: Vec<ToolCall>,\n    pub id: Option<String>,\n    pub input_tokens: Option<u64>,\n    pub output_tokens: Option<u64>,\n}\n\nimpl ChatCompletionsOutput {\n    pub fn new(text: &str) -> Self {\n        Self {\n            text: text.to_string(),\n            ..Default::default()\n        }\n    }\n}\n\n#[derive(Debug)]\npub struct EmbeddingsData {\n    pub texts: Vec<String>,\n    pub query: bool,\n}\n\nimpl EmbeddingsData {\n    pub fn new(texts: Vec<String>, query: bool) -> Self {\n        Self { texts, query }\n    }\n}\n\npub type EmbeddingsOutput = Vec<Vec<f32>>;\n\n#[derive(Debug)]\npub struct RerankData {\n    pub query: String,\n    pub documents: Vec<String>,\n    pub top_n: usize,\n}\n\nimpl RerankData {\n    pub fn new(query: String, documents: Vec<String>, top_n: usize) -> Self {\n        Self {\n            query,\n            documents,\n            top_n,\n        }\n    }\n}\n\npub type RerankOutput = Vec<RerankResult>;\n\n#[derive(Debug, Deserialize)]\npub struct RerankResult {\n    pub index: usize,\n    pub relevance_score: f64,\n}\n\npub type PromptAction<'a> = (&'a str, &'a str, Option<&'a str>);\n\npub async fn create_config(\n    prompts: &[PromptAction<'static>],\n    client: &str,\n) -> Result<(String, Value)> {\n    let mut config = json!({\n        \"type\": client,\n    });\n    for (key, desc, help_message) in prompts {\n        let env_name = format!(\"{client}_{key}\").to_ascii_uppercase();\n        let required = std::env::var(&env_name).is_err();\n        let value = prompt_input_string(desc, required, *help_message)?;\n        if !value.is_empty() {\n            config[key] = value.into();\n        }\n    }\n    let model = set_client_models_config(&mut config, client).await?;\n    let clients = json!(vec![config]);\n    Ok((model, clients))\n}\n\npub async fn create_openai_compatible_client_config(\n    client: &str,\n) -> Result<Option<(String, Value)>> {\n    let api_base = super::OPENAI_COMPATIBLE_PROVIDERS\n        .into_iter()\n        .find(|(name, _)| client == *name)\n        .map(|(_, api_base)| api_base)\n        .unwrap_or(\"http(s)://{API_ADDR}/v1\");\n\n    let name = if client == OpenAICompatibleClient::NAME {\n        let value = prompt_input_string(\"Provider Name\", true, None)?;\n        value.replace(' ', \"-\")\n    } else {\n        client.to_string()\n    };\n\n    let mut config = json!({\n        \"type\": OpenAICompatibleClient::NAME,\n        \"name\": &name,\n    });\n\n    let api_base = if api_base.contains('{') {\n        prompt_input_string(\"API Base\", true, Some(&format!(\"e.g. {api_base}\")))?\n    } else {\n        api_base.to_string()\n    };\n    config[\"api_base\"] = api_base.into();\n\n    let api_key = prompt_input_string(\"API Key\", false, None)?;\n    if !api_key.is_empty() {\n        config[\"api_key\"] = api_key.into();\n    }\n\n    let model = set_client_models_config(&mut config, &name).await?;\n    let clients = json!(vec![config]);\n    Ok(Some((model, clients)))\n}\n\npub async fn call_chat_completions(\n    input: &Input,\n    print: bool,\n    extract_code: bool,\n    client: &dyn Client,\n    abort_signal: AbortSignal,\n) -> Result<(String, Vec<ToolResult>)> {\n    let ret = abortable_run_with_spinner(\n        client.chat_completions(input.clone()),\n        \"Generating\",\n        abort_signal,\n    )\n    .await;\n\n    match ret {\n        Ok(ret) => {\n            let ChatCompletionsOutput {\n                mut text,\n                tool_calls,\n                ..\n            } = ret;\n            if !text.is_empty() {\n                if extract_code {\n                    text = extract_code_block(&strip_think_tag(&text)).to_string();\n                }\n                if print {\n                    client.global_config().read().print_markdown(&text)?;\n                }\n            }\n            Ok((text, eval_tool_calls(client.global_config(), tool_calls)?))\n        }\n        Err(err) => Err(err),\n    }\n}\n\npub async fn call_chat_completions_streaming(\n    input: &Input,\n    client: &dyn Client,\n    abort_signal: AbortSignal,\n) -> Result<(String, Vec<ToolResult>)> {\n    let (tx, rx) = unbounded_channel();\n    let mut handler = SseHandler::new(tx, abort_signal.clone());\n\n    let (send_ret, render_ret) = tokio::join!(\n        client.chat_completions_streaming(input, &mut handler),\n        render_stream(rx, client.global_config(), abort_signal.clone()),\n    );\n\n    if handler.abort().aborted() {\n        bail!(\"Aborted.\");\n    }\n\n    render_ret?;\n\n    let (text, tool_calls) = handler.take();\n    match send_ret {\n        Ok(_) => {\n            if !text.is_empty() && !text.ends_with('\\n') {\n                println!();\n            }\n            Ok((text, eval_tool_calls(client.global_config(), tool_calls)?))\n        }\n        Err(err) => {\n            if !text.is_empty() {\n                println!();\n            }\n            Err(err)\n        }\n    }\n}\n\npub fn noop_prepare_embeddings<T>(_client: &T, _data: &EmbeddingsData) -> Result<RequestData> {\n    bail!(\"The client doesn't support embeddings api\")\n}\n\npub async fn noop_embeddings(_builder: RequestBuilder, _model: &Model) -> Result<EmbeddingsOutput> {\n    bail!(\"The client doesn't support embeddings api\")\n}\n\npub fn noop_prepare_rerank<T>(_client: &T, _data: &RerankData) -> Result<RequestData> {\n    bail!(\"The client doesn't support rerank api\")\n}\n\npub async fn noop_rerank(_builder: RequestBuilder, _model: &Model) -> Result<RerankOutput> {\n    bail!(\"The client doesn't support rerank api\")\n}\n\npub fn catch_error(data: &Value, status: u16) -> Result<()> {\n    if (200..300).contains(&status) {\n        return Ok(());\n    }\n    debug!(\"Invalid response, status: {status}, data: {data}\");\n    if let Some(error) = data[\"error\"].as_object() {\n        if let (Some(typ), Some(message)) = (\n            json_str_from_map(error, \"type\"),\n            json_str_from_map(error, \"message\"),\n        ) {\n            bail!(\"{message} (type: {typ})\");\n        } else if let (Some(typ), Some(message)) = (\n            json_str_from_map(error, \"code\"),\n            json_str_from_map(error, \"message\"),\n        ) {\n            bail!(\"{message} (code: {typ})\");\n        }\n    } else if let Some(error) = data[\"errors\"][0].as_object() {\n        if let (Some(code), Some(message)) = (\n            error.get(\"code\").and_then(|v| v.as_u64()),\n            json_str_from_map(error, \"message\"),\n        ) {\n            bail!(\"{message} (status: {code})\")\n        }\n    } else if let Some(error) = data[0][\"error\"].as_object() {\n        if let (Some(status), Some(message)) = (\n            json_str_from_map(error, \"status\"),\n            json_str_from_map(error, \"message\"),\n        ) {\n            bail!(\"{message} (status: {status})\")\n        }\n    } else if let (Some(detail), Some(status)) = (data[\"detail\"].as_str(), data[\"status\"].as_i64())\n    {\n        bail!(\"{detail} (status: {status})\");\n    } else if let Some(error) = data[\"error\"].as_str() {\n        bail!(\"{error}\");\n    } else if let Some(message) = data[\"message\"].as_str() {\n        bail!(\"{message}\");\n    }\n    bail!(\"Invalid response data: {data} (status: {status})\");\n}\n\npub fn json_str_from_map<'a>(\n    map: &'a serde_json::Map<String, Value>,\n    field_name: &str,\n) -> Option<&'a str> {\n    map.get(field_name).and_then(|v| v.as_str())\n}\n\nasync fn set_client_models_config(client_config: &mut Value, client: &str) -> Result<String> {\n    if let Some(provider) = ALL_PROVIDER_MODELS.iter().find(|v| v.provider == client) {\n        let models: Vec<String> = provider\n            .models\n            .iter()\n            .filter(|v| v.model_type == \"chat\")\n            .map(|v| v.name.clone())\n            .collect();\n        let model_name = select_model(models)?;\n        return Ok(format!(\"{client}:{model_name}\"));\n    }\n    let mut model_names = vec![];\n    if let (Some(true), Some(api_base), api_key) = (\n        client_config[\"type\"]\n            .as_str()\n            .map(|v| v == OpenAICompatibleClient::NAME),\n        client_config[\"api_base\"].as_str(),\n        client_config[\"api_key\"]\n            .as_str()\n            .map(|v| v.to_string())\n            .or_else(|| {\n                let env_name = format!(\"{client}_api_key\").to_ascii_uppercase();\n                std::env::var(&env_name).ok()\n            }),\n    ) {\n        match abortable_run_with_spinner(\n            fetch_models(api_base, api_key.as_deref()),\n            \"Fetching models\",\n            create_abort_signal(),\n        )\n        .await\n        {\n            Ok(fetched_models) => {\n                model_names = MultiSelect::new(\"LLMs to include (required):\", fetched_models)\n                    .with_validator(|list: &[ListOption<&String>]| {\n                        if list.is_empty() {\n                            Ok(Validation::Invalid(\n                                \"At least one item must be selected\".into(),\n                            ))\n                        } else {\n                            Ok(Validation::Valid)\n                        }\n                    })\n                    .prompt()?;\n            }\n            Err(err) => {\n                eprintln!(\"✗ Fetch models failed: {err}\");\n            }\n        }\n    }\n    if model_names.is_empty() {\n        model_names = prompt_input_string(\n            \"LLMs to add\",\n            true,\n            Some(\"Separated by commas, e.g. llama3.3,qwen2.5\"),\n        )?\n        .split(',')\n        .filter_map(|v| {\n            let v = v.trim();\n            if v.is_empty() {\n                None\n            } else {\n                Some(v.to_string())\n            }\n        })\n        .collect::<Vec<_>>();\n    }\n    if model_names.is_empty() {\n        bail!(\"No models\");\n    }\n    let models: Vec<Value> = model_names\n        .iter()\n        .map(|v| {\n            let l = v.to_lowercase();\n            if l.contains(\"rank\") {\n                json!({\n                    \"name\": v,\n                    \"type\": \"reranker\",\n                })\n            } else if let Ok(true) = EMBEDDING_MODEL_RE.is_match(&l) {\n                json!({\n                    \"name\": v,\n                    \"type\": \"embedding\",\n                    \"default_chunk_size\": 1000,\n                    \"max_batch_size\": 100\n                })\n            } else if v.contains(\"vision\") {\n                json!({\n                    \"name\": v,\n                    \"supports_vision\": true\n                })\n            } else {\n                json!({\n                    \"name\": v,\n                })\n            }\n        })\n        .collect();\n    client_config[\"models\"] = models.into();\n    let model_name = select_model(model_names)?;\n    Ok(format!(\"{client}:{model_name}\"))\n}\n\nfn select_model(model_names: Vec<String>) -> Result<String> {\n    if model_names.is_empty() {\n        bail!(\"No models\");\n    }\n    let model = if model_names.len() == 1 {\n        model_names[0].clone()\n    } else {\n        Select::new(\"Default Model (required):\", model_names).prompt()?\n    };\n    Ok(model)\n}\n\nfn prompt_input_string(\n    desc: &str,\n    required: bool,\n    help_message: Option<&str>,\n) -> anyhow::Result<String> {\n    let desc = if required {\n        format!(\"{desc} (required):\")\n    } else {\n        format!(\"{desc} (optional):\")\n    };\n    let mut text = Text::new(&desc);\n    if required {\n        text = text.with_validator(required!(\"This field is required\"))\n    }\n    if let Some(help_message) = help_message {\n        text = text.with_help_message(help_message);\n    }\n    let text = text.prompt()?;\n    Ok(text)\n}\n"
  },
  {
    "path": "src/client/gemini.rs",
    "content": "use super::vertexai::*;\nuse super::*;\n\nuse anyhow::{Context, Result};\nuse reqwest::RequestBuilder;\nuse serde::Deserialize;\nuse serde_json::{json, Value};\n\nconst API_BASE: &str = \"https://generativelanguage.googleapis.com/v1beta\";\n\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct GeminiConfig {\n    pub name: Option<String>,\n    pub api_key: Option<String>,\n    pub api_base: Option<String>,\n    #[serde(default)]\n    pub models: Vec<ModelData>,\n    pub patch: Option<RequestPatch>,\n    pub extra: Option<ExtraConfig>,\n}\n\nimpl GeminiClient {\n    config_get_fn!(api_key, get_api_key);\n    config_get_fn!(api_base, get_api_base);\n\n    pub const PROMPTS: [PromptAction<'static>; 1] = [(\"api_key\", \"API Key\", None)];\n}\n\nimpl_client_trait!(\n    GeminiClient,\n    (\n        prepare_chat_completions,\n        gemini_chat_completions,\n        gemini_chat_completions_streaming\n    ),\n    (prepare_embeddings, embeddings),\n    (noop_prepare_rerank, noop_rerank),\n);\n\nfn prepare_chat_completions(\n    self_: &GeminiClient,\n    data: ChatCompletionsData,\n) -> Result<RequestData> {\n    let api_key = self_.get_api_key()?;\n    let api_base = self_\n        .get_api_base()\n        .unwrap_or_else(|_| API_BASE.to_string());\n\n    let func = match data.stream {\n        true => \"streamGenerateContent\",\n        false => \"generateContent\",\n    };\n\n    let url = format!(\n        \"{}/models/{}:{}\",\n        api_base.trim_end_matches('/'),\n        self_.model.real_name(),\n        func\n    );\n\n    let body = gemini_build_chat_completions_body(data, &self_.model)?;\n\n    let mut request_data = RequestData::new(url, body);\n\n    request_data.header(\"x-goog-api-key\", api_key);\n\n    Ok(request_data)\n}\n\nfn prepare_embeddings(self_: &GeminiClient, data: &EmbeddingsData) -> Result<RequestData> {\n    let api_key = self_.get_api_key()?;\n    let api_base = self_\n        .get_api_base()\n        .unwrap_or_else(|_| API_BASE.to_string());\n\n    let url = format!(\n        \"{}/models/{}:batchEmbedContents?key={}\",\n        api_base.trim_end_matches('/'),\n        self_.model.real_name(),\n        api_key\n    );\n\n    let model_id = format!(\"models/{}\", self_.model.real_name());\n\n    let requests: Vec<_> = data\n        .texts\n        .iter()\n        .map(|text| {\n            json!({\n                \"model\": model_id,\n                \"content\": {\n                    \"parts\": [\n                        {\n                            \"text\": text\n                        }\n                    ]\n                },\n            })\n        })\n        .collect();\n\n    let body = json!({\n        \"requests\": requests,\n    });\n\n    let request_data = RequestData::new(url, body);\n\n    Ok(request_data)\n}\n\nasync fn embeddings(builder: RequestBuilder, _model: &Model) -> Result<EmbeddingsOutput> {\n    let res = builder.send().await?;\n    let status = res.status();\n    let data: Value = res.json().await?;\n    if !status.is_success() {\n        catch_error(&data, status.as_u16())?;\n    }\n    let res_body: EmbeddingsResBody =\n        serde_json::from_value(data).context(\"Invalid embeddings data\")?;\n    let output = res_body\n        .embeddings\n        .into_iter()\n        .map(|embedding| embedding.values)\n        .collect();\n    Ok(output)\n}\n\n#[derive(Deserialize)]\nstruct EmbeddingsResBody {\n    embeddings: Vec<EmbeddingsResBodyEmbedding>,\n}\n\n#[derive(Deserialize)]\nstruct EmbeddingsResBodyEmbedding {\n    values: Vec<f32>,\n}\n"
  },
  {
    "path": "src/client/macros.rs",
    "content": "#[macro_export]\nmacro_rules! register_client {\n    (\n        $(($module:ident, $name:literal, $config:ident, $client:ident),)+\n    ) => {\n        $(\n            mod $module;\n        )+\n        $(\n            use self::$module::$config;\n        )+\n\n        #[derive(Debug, Clone, serde::Deserialize)]\n        #[serde(tag = \"type\")]\n        pub enum ClientConfig {\n            $(\n                #[serde(rename = $name)]\n                $config($config),\n            )+\n            #[serde(other)]\n            Unknown,\n        }\n\n        $(\n            #[derive(Debug)]\n            pub struct $client {\n                global_config: $crate::config::GlobalConfig,\n                config: $config,\n                model: $crate::client::Model,\n            }\n\n            impl $client {\n                pub const NAME: &'static str = $name;\n\n                pub fn init(global_config: &$crate::config::GlobalConfig, model: &$crate::client::Model) -> Option<Box<dyn Client>> {\n                    let config = global_config.read().clients.iter().find_map(|client_config| {\n                        if let ClientConfig::$config(c) = client_config {\n                            if Self::name(c) == model.client_name() {\n                                return Some(c.clone())\n                            }\n                        }\n                        None\n                    })?;\n\n                    Some(Box::new(Self {\n                        global_config: global_config.clone(),\n                        config,\n                        model: model.clone(),\n                    }))\n                }\n\n                pub fn list_models(local_config: &$config) -> Vec<Model> {\n                    let client_name = Self::name(local_config);\n                    if local_config.models.is_empty() {\n                        if let Some(v) = $crate::client::ALL_PROVIDER_MODELS.iter().find(|v| {\n                            v.provider == $name ||\n                                ($name == OpenAICompatibleClient::NAME\n                                    && local_config.name.as_ref().map(|name| name.starts_with(&v.provider)).unwrap_or_default())\n                        }) {\n                            return Model::from_config(client_name, &v.models);\n                        }\n                        vec![]\n                    } else {\n                        Model::from_config(client_name, &local_config.models)\n                    }\n                }\n\n                pub fn name(local_config: &$config) -> &str {\n                    local_config.name.as_deref().unwrap_or(Self::NAME)\n                }\n            }\n\n        )+\n\n        pub fn init_client(config: &$crate::config::GlobalConfig, model: Option<$crate::client::Model>) -> anyhow::Result<Box<dyn Client>> {\n            let model = model.unwrap_or_else(|| config.read().model.clone());\n            None\n            $(.or_else(|| $client::init(config, &model)))+\n            .ok_or_else(|| {\n                anyhow::anyhow!(\"Invalid model '{}'\", model.id())\n            })\n        }\n\n        pub fn list_client_types() -> Vec<&'static str> {\n            let mut client_types: Vec<_> = vec![$($client::NAME,)+];\n            client_types.extend($crate::client::OPENAI_COMPATIBLE_PROVIDERS.iter().map(|(name, _)| *name));\n            client_types\n        }\n\n        pub async fn create_client_config(client: &str) -> anyhow::Result<(String, serde_json::Value)> {\n            $(\n                if client == $client::NAME && client != $crate::client::OpenAICompatibleClient::NAME {\n                    return create_config(&$client::PROMPTS, $client::NAME).await\n                }\n            )+\n            if let Some(ret) = create_openai_compatible_client_config(client).await? {\n                return Ok(ret);\n            }\n            anyhow::bail!(\"Unknown client '{}'\", client)\n        }\n\n        static ALL_CLIENT_NAMES: std::sync::OnceLock<Vec<String>> = std::sync::OnceLock::new();\n\n        pub fn list_client_names(config: &$crate::config::Config) -> Vec<&'static String> {\n            let names = ALL_CLIENT_NAMES.get_or_init(|| {\n                config\n                    .clients\n                    .iter()\n                    .flat_map(|v| match v {\n                        $(ClientConfig::$config(c) => vec![$client::name(c).to_string()],)+\n                        ClientConfig::Unknown => vec![],\n                    })\n                    .collect()\n            });\n            names.iter().collect()\n        }\n\n        static ALL_MODELS: std::sync::OnceLock<Vec<$crate::client::Model>> = std::sync::OnceLock::new();\n\n        pub fn list_all_models(config: &$crate::config::Config) -> Vec<&'static $crate::client::Model> {\n            let models = ALL_MODELS.get_or_init(|| {\n                config\n                    .clients\n                    .iter()\n                    .flat_map(|v| match v {\n                        $(ClientConfig::$config(c) => $client::list_models(c),)+\n                        ClientConfig::Unknown => vec![],\n                    })\n                    .collect()\n            });\n            models.iter().collect()\n        }\n\n        pub fn list_models(config: &$crate::config::Config, model_type: $crate::client::ModelType) -> Vec<&'static $crate::client::Model> {\n            list_all_models(config).into_iter().filter(|v| v.model_type() == model_type).collect()\n        }\n    };\n}\n\n#[macro_export]\nmacro_rules! client_common_fns {\n    () => {\n        fn global_config(&self) -> &$crate::config::GlobalConfig {\n            &self.global_config\n        }\n\n        fn extra_config(&self) -> Option<&$crate::client::ExtraConfig> {\n            self.config.extra.as_ref()\n        }\n\n        fn patch_config(&self) -> Option<&$crate::client::RequestPatch> {\n            self.config.patch.as_ref()\n        }\n\n        fn name(&self) -> &str {\n            Self::name(&self.config)\n        }\n\n        fn model(&self) -> &Model {\n            &self.model\n        }\n\n        fn model_mut(&mut self) -> &mut Model {\n            &mut self.model\n        }\n    };\n}\n\n#[macro_export]\nmacro_rules! impl_client_trait {\n    (\n        $client:ident,\n        ($prepare_chat_completions:path, $chat_completions:path, $chat_completions_streaming:path),\n        ($prepare_embeddings:path, $embeddings:path),\n        ($prepare_rerank:path, $rerank:path),\n    ) => {\n        #[async_trait::async_trait]\n        impl $crate::client::Client for $crate::client::$client {\n            client_common_fns!();\n\n            async fn chat_completions_inner(\n                &self,\n                client: &reqwest::Client,\n                data: $crate::client::ChatCompletionsData,\n            ) -> anyhow::Result<$crate::client::ChatCompletionsOutput> {\n                let request_data = $prepare_chat_completions(self, data)?;\n                let builder = self.request_builder(client, request_data);\n                $chat_completions(builder, self.model()).await\n            }\n\n            async fn chat_completions_streaming_inner(\n                &self,\n                client: &reqwest::Client,\n                handler: &mut $crate::client::SseHandler,\n                data: $crate::client::ChatCompletionsData,\n            ) -> Result<()> {\n                let request_data = $prepare_chat_completions(self, data)?;\n                let builder = self.request_builder(client, request_data);\n                $chat_completions_streaming(builder, handler, self.model()).await\n            }\n\n            async fn embeddings_inner(\n                &self,\n                client: &reqwest::Client,\n                data: &$crate::client::EmbeddingsData,\n            ) -> Result<$crate::client::EmbeddingsOutput> {\n                let request_data = $prepare_embeddings(self, data)?;\n                let builder = self.request_builder(client, request_data);\n                $embeddings(builder, self.model()).await\n            }\n\n            async fn rerank_inner(\n                &self,\n                client: &reqwest::Client,\n                data: &$crate::client::RerankData,\n            ) -> Result<$crate::client::RerankOutput> {\n                let request_data = $prepare_rerank(self, data)?;\n                let builder = self.request_builder(client, request_data);\n                $rerank(builder, self.model()).await\n            }\n        }\n    };\n}\n\n#[macro_export]\nmacro_rules! config_get_fn {\n    ($field_name:ident, $fn_name:ident) => {\n        fn $fn_name(&self) -> anyhow::Result<String> {\n            let env_prefix = Self::name(&self.config);\n            let env_name =\n                format!(\"{}_{}\", env_prefix, stringify!($field_name)).to_ascii_uppercase();\n            std::env::var(&env_name)\n                .ok()\n                .or_else(|| self.config.$field_name.clone())\n                .ok_or_else(|| anyhow::anyhow!(\"Miss '{}'\", stringify!($field_name)))\n        }\n    };\n}\n\n#[macro_export]\nmacro_rules! unsupported_model {\n    ($name:expr) => {\n        anyhow::bail!(\"Unsupported model '{}'\", $name)\n    };\n}\n"
  },
  {
    "path": "src/client/message.rs",
    "content": "use super::Model;\n\nuse crate::{function::ToolResult, multiline_text, utils::dimmed_text};\n\nuse serde::{Deserialize, Serialize};\n\n#[derive(Debug, Clone, Deserialize, Serialize)]\npub struct Message {\n    pub role: MessageRole,\n    pub content: MessageContent,\n}\n\nimpl Default for Message {\n    fn default() -> Self {\n        Self {\n            role: MessageRole::User,\n            content: MessageContent::Text(String::new()),\n        }\n    }\n}\n\nimpl Message {\n    pub fn new(role: MessageRole, content: MessageContent) -> Self {\n        Self { role, content }\n    }\n\n    pub fn merge_system(&mut self, system: MessageContent) {\n        match (&mut self.content, system) {\n            (MessageContent::Text(text), MessageContent::Text(system_text)) => {\n                self.content = MessageContent::Array(vec![\n                    MessageContentPart::Text { text: system_text },\n                    MessageContentPart::Text {\n                        text: text.to_string(),\n                    },\n                ])\n            }\n            (MessageContent::Array(list), MessageContent::Text(system_text)) => {\n                list.insert(0, MessageContentPart::Text { text: system_text })\n            }\n            (MessageContent::Text(text), MessageContent::Array(mut system_list)) => {\n                system_list.push(MessageContentPart::Text {\n                    text: text.to_string(),\n                });\n                self.content = MessageContent::Array(system_list);\n            }\n            (MessageContent::Array(list), MessageContent::Array(mut system_list)) => {\n                system_list.append(list);\n                self.content = MessageContent::Array(system_list);\n            }\n            _ => {}\n        }\n    }\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum MessageRole {\n    System,\n    Assistant,\n    User,\n    Tool,\n}\n\n#[allow(dead_code)]\nimpl MessageRole {\n    pub fn is_system(&self) -> bool {\n        matches!(self, MessageRole::System)\n    }\n\n    pub fn is_user(&self) -> bool {\n        matches!(self, MessageRole::User)\n    }\n\n    pub fn is_assistant(&self) -> bool {\n        matches!(self, MessageRole::Assistant)\n    }\n}\n\n#[derive(Debug, Clone, Deserialize, Serialize)]\n#[serde(untagged)]\npub enum MessageContent {\n    Text(String),\n    Array(Vec<MessageContentPart>),\n    // Note: This type is primarily for convenience and does not exist in OpenAI's API.\n    ToolCalls(MessageContentToolCalls),\n}\n\nimpl MessageContent {\n    pub fn render_input(\n        &self,\n        resolve_url_fn: impl Fn(&str) -> String,\n        agent_info: &Option<(String, Vec<String>)>,\n    ) -> String {\n        match self {\n            MessageContent::Text(text) => multiline_text(text),\n            MessageContent::Array(list) => {\n                let (mut concated_text, mut files) = (String::new(), vec![]);\n                for item in list {\n                    match item {\n                        MessageContentPart::Text { text } => {\n                            concated_text = format!(\"{concated_text} {text}\")\n                        }\n                        MessageContentPart::ImageUrl { image_url } => {\n                            files.push(resolve_url_fn(&image_url.url))\n                        }\n                    }\n                }\n                if !concated_text.is_empty() {\n                    concated_text = format!(\" -- {}\", multiline_text(&concated_text))\n                }\n                format!(\".file {}{}\", files.join(\" \"), concated_text)\n            }\n            MessageContent::ToolCalls(MessageContentToolCalls {\n                tool_results, text, ..\n            }) => {\n                let mut lines = vec![];\n                if !text.is_empty() {\n                    lines.push(text.clone())\n                }\n                for tool_result in tool_results {\n                    let mut parts = vec![\"Call\".to_string()];\n                    if let Some((agent_name, functions)) = agent_info {\n                        if functions.contains(&tool_result.call.name) {\n                            parts.push(agent_name.clone())\n                        }\n                    }\n                    parts.push(tool_result.call.name.clone());\n                    parts.push(tool_result.call.arguments.to_string());\n                    lines.push(dimmed_text(&parts.join(\" \")));\n                }\n                lines.join(\"\\n\")\n            }\n        }\n    }\n\n    pub fn merge_prompt(&mut self, replace_fn: impl Fn(&str) -> String) {\n        match self {\n            MessageContent::Text(text) => *text = replace_fn(text),\n            MessageContent::Array(list) => {\n                if list.is_empty() {\n                    list.push(MessageContentPart::Text {\n                        text: replace_fn(\"\"),\n                    })\n                } else if let Some(MessageContentPart::Text { text }) = list.get_mut(0) {\n                    *text = replace_fn(text)\n                }\n            }\n            MessageContent::ToolCalls(_) => {}\n        }\n    }\n\n    pub fn to_text(&self) -> String {\n        match self {\n            MessageContent::Text(text) => text.to_string(),\n            MessageContent::Array(list) => {\n                let mut parts = vec![];\n                for item in list {\n                    if let MessageContentPart::Text { text } = item {\n                        parts.push(text.clone())\n                    }\n                }\n                parts.join(\"\\n\\n\")\n            }\n            MessageContent::ToolCalls(_) => String::new(),\n        }\n    }\n}\n\n#[derive(Debug, Clone, Deserialize, Serialize)]\n#[serde(tag = \"type\", rename_all = \"snake_case\")]\npub enum MessageContentPart {\n    Text { text: String },\n    ImageUrl { image_url: ImageUrl },\n}\n\n#[derive(Debug, Clone, Deserialize, Serialize)]\npub struct ImageUrl {\n    pub url: String,\n}\n\n#[derive(Debug, Clone, Deserialize, Serialize)]\npub struct MessageContentToolCalls {\n    pub tool_results: Vec<ToolResult>,\n    pub text: String,\n    pub sequence: bool,\n}\n\nimpl MessageContentToolCalls {\n    pub fn new(tool_results: Vec<ToolResult>, text: String) -> Self {\n        Self {\n            tool_results,\n            text,\n            sequence: false,\n        }\n    }\n\n    pub fn merge(&mut self, tool_results: Vec<ToolResult>, _text: String) {\n        self.tool_results.extend(tool_results);\n        self.text.clear();\n        self.sequence = true;\n    }\n}\n\npub fn patch_messages(messages: &mut Vec<Message>, model: &Model) {\n    if messages.is_empty() {\n        return;\n    }\n    if let Some(prefix) = model.system_prompt_prefix() {\n        if messages[0].role.is_system() {\n            messages[0].merge_system(MessageContent::Text(prefix.to_string()));\n        } else {\n            messages.insert(\n                0,\n                Message {\n                    role: MessageRole::System,\n                    content: MessageContent::Text(prefix.to_string()),\n                },\n            );\n        }\n    }\n    if model.no_system_message() && messages[0].role.is_system() {\n        let system_message = messages.remove(0);\n        if let (Some(message), system) = (messages.get_mut(0), system_message.content) {\n            message.merge_system(system);\n        }\n    }\n}\n\npub fn extract_system_message(messages: &mut Vec<Message>) -> Option<String> {\n    if messages[0].role.is_system() {\n        let system_message = messages.remove(0);\n        return Some(system_message.content.to_text());\n    }\n    None\n}\n"
  },
  {
    "path": "src/client/mod.rs",
    "content": "mod access_token;\nmod common;\nmod message;\n#[macro_use]\nmod macros;\nmod model;\nmod stream;\n\npub use crate::function::ToolCall;\npub use common::*;\npub use message::*;\npub use model::*;\npub use stream::*;\n\nregister_client!(\n    (openai, \"openai\", OpenAIConfig, OpenAIClient),\n    (\n        openai_compatible,\n        \"openai-compatible\",\n        OpenAICompatibleConfig,\n        OpenAICompatibleClient\n    ),\n    (gemini, \"gemini\", GeminiConfig, GeminiClient),\n    (claude, \"claude\", ClaudeConfig, ClaudeClient),\n    (cohere, \"cohere\", CohereConfig, CohereClient),\n    (\n        azure_openai,\n        \"azure-openai\",\n        AzureOpenAIConfig,\n        AzureOpenAIClient\n    ),\n    (vertexai, \"vertexai\", VertexAIConfig, VertexAIClient),\n    (bedrock, \"bedrock\", BedrockConfig, BedrockClient),\n);\n\npub const OPENAI_COMPATIBLE_PROVIDERS: [(&str, &str); 18] = [\n    (\"ai21\", \"https://api.ai21.com/studio/v1\"),\n    (\n        \"cloudflare\",\n        \"https://api.cloudflare.com/client/v4/accounts/{ACCOUNT_ID}/ai/v1\",\n    ),\n    (\"deepinfra\", \"https://api.deepinfra.com/v1/openai\"),\n    (\"deepseek\", \"https://api.deepseek.com\"),\n    (\"ernie\", \"https://qianfan.baidubce.com/v2\"),\n    (\"github\", \"https://models.inference.ai.azure.com\"),\n    (\"groq\", \"https://api.groq.com/openai/v1\"),\n    (\"hunyuan\", \"https://api.hunyuan.cloud.tencent.com/v1\"),\n    (\"minimax\", \"https://api.minimax.chat/v1\"),\n    (\"mistral\", \"https://api.mistral.ai/v1\"),\n    (\"moonshot\", \"https://api.moonshot.cn/v1\"),\n    (\"openrouter\", \"https://openrouter.ai/api/v1\"),\n    (\"perplexity\", \"https://api.perplexity.ai\"),\n    (\n        \"qianwen\",\n        \"https://dashscope.aliyuncs.com/compatible-mode/v1\",\n    ),\n    (\"xai\", \"https://api.x.ai/v1\"),\n    (\"zhipuai\", \"https://open.bigmodel.cn/api/paas/v4\"),\n    // RAG-dedicated\n    (\"jina\", \"https://api.jina.ai/v1\"),\n    (\"voyageai\", \"https://api.voyageai.com/v1\"),\n];\n"
  },
  {
    "path": "src/client/model.rs",
    "content": "use super::{\n    list_all_models, list_client_names,\n    message::{Message, MessageContent, MessageContentPart},\n    ApiPatch, MessageContentToolCalls, RequestPatch,\n};\n\nuse crate::config::Config;\nuse crate::utils::{estimate_token_length, strip_think_tag};\n\nuse anyhow::{bail, Result};\nuse serde::{Deserialize, Serialize};\nuse serde_json::Value;\nuse std::fmt::Display;\n\nconst PER_MESSAGES_TOKENS: usize = 5;\nconst BASIS_TOKENS: usize = 2;\n\n#[derive(Debug, Clone)]\npub struct Model {\n    client_name: String,\n    data: ModelData,\n}\n\nimpl Default for Model {\n    fn default() -> Self {\n        Model::new(\"\", \"\")\n    }\n}\n\nimpl Model {\n    pub fn new(client_name: &str, name: &str) -> Self {\n        Self {\n            client_name: client_name.into(),\n            data: ModelData::new(name),\n        }\n    }\n\n    pub fn from_config(client_name: &str, models: &[ModelData]) -> Vec<Self> {\n        models\n            .iter()\n            .map(|v| Model {\n                client_name: client_name.to_string(),\n                data: v.clone(),\n            })\n            .collect()\n    }\n\n    pub fn retrieve_model(config: &Config, model_id: &str, model_type: ModelType) -> Result<Self> {\n        let models = list_all_models(config);\n        let (client_name, model_name) = match model_id.split_once(':') {\n            Some((client_name, model_name)) => {\n                if model_name.is_empty() {\n                    (client_name, None)\n                } else {\n                    (client_name, Some(model_name))\n                }\n            }\n            None => (model_id, None),\n        };\n        match model_name {\n            Some(model_name) => {\n                if let Some(model) = models.iter().find(|v| v.id() == model_id) {\n                    if model.model_type() == model_type {\n                        return Ok((*model).clone());\n                    } else {\n                        bail!(\"Model '{model_id}' is not a {model_type} model\")\n                    }\n                }\n                if list_client_names(config)\n                    .into_iter()\n                    .any(|v| *v == client_name)\n                    && model_type.can_create_from_name()\n                {\n                    let mut new_model = Self::new(client_name, model_name);\n                    new_model.data.model_type = model_type.to_string();\n                    return Ok(new_model);\n                }\n            }\n            None => {\n                if let Some(found) = models\n                    .iter()\n                    .find(|v| v.client_name == client_name && v.model_type() == model_type)\n                {\n                    return Ok((*found).clone());\n                }\n            }\n        };\n        bail!(\"Unknown {model_type} model '{model_id}'\")\n    }\n\n    pub fn id(&self) -> String {\n        if self.data.name.is_empty() {\n            self.client_name.to_string()\n        } else {\n            format!(\"{}:{}\", self.client_name, self.data.name)\n        }\n    }\n\n    pub fn client_name(&self) -> &str {\n        &self.client_name\n    }\n\n    pub fn name(&self) -> &str {\n        &self.data.name\n    }\n\n    pub fn real_name(&self) -> &str {\n        self.data.real_name.as_deref().unwrap_or(&self.data.name)\n    }\n\n    pub fn model_type(&self) -> ModelType {\n        if self.data.model_type.starts_with(\"embed\") {\n            ModelType::Embedding\n        } else if self.data.model_type.starts_with(\"rerank\") {\n            ModelType::Reranker\n        } else {\n            ModelType::Chat\n        }\n    }\n\n    pub fn data(&self) -> &ModelData {\n        &self.data\n    }\n\n    pub fn data_mut(&mut self) -> &mut ModelData {\n        &mut self.data\n    }\n\n    pub fn description(&self) -> String {\n        match self.model_type() {\n            ModelType::Chat => {\n                let ModelData {\n                    max_input_tokens,\n                    max_output_tokens,\n                    input_price,\n                    output_price,\n                    supports_vision,\n                    supports_function_calling,\n                    ..\n                } = &self.data;\n                let max_input_tokens = stringify_option_value(max_input_tokens);\n                let max_output_tokens = stringify_option_value(max_output_tokens);\n                let input_price = stringify_option_value(input_price);\n                let output_price = stringify_option_value(output_price);\n                let mut capabilities = vec![];\n                if *supports_vision {\n                    capabilities.push('👁');\n                };\n                if *supports_function_calling {\n                    capabilities.push('⚒');\n                };\n                let capabilities: String = capabilities\n                    .into_iter()\n                    .map(|v| format!(\"{v} \"))\n                    .collect::<Vec<String>>()\n                    .join(\"\");\n                format!(\n                    \"{max_input_tokens:>8} / {max_output_tokens:>8}  |  {input_price:>6} / {output_price:>6}  {capabilities:>6}\"\n                )\n            }\n            ModelType::Embedding => {\n                let ModelData {\n                    input_price,\n                    max_tokens_per_chunk,\n                    max_batch_size,\n                    ..\n                } = &self.data;\n                let max_tokens = stringify_option_value(max_tokens_per_chunk);\n                let max_batch = stringify_option_value(max_batch_size);\n                let price = stringify_option_value(input_price);\n                format!(\"max-tokens:{max_tokens};max-batch:{max_batch};price:{price}\")\n            }\n            ModelType::Reranker => String::new(),\n        }\n    }\n\n    pub fn patch(&self) -> Option<&Value> {\n        self.data.patch.as_ref()\n    }\n\n    pub fn max_input_tokens(&self) -> Option<usize> {\n        self.data.max_input_tokens\n    }\n\n    pub fn max_output_tokens(&self) -> Option<isize> {\n        self.data.max_output_tokens\n    }\n\n    pub fn no_stream(&self) -> bool {\n        self.data.no_stream\n    }\n\n    pub fn no_system_message(&self) -> bool {\n        self.data.no_system_message\n    }\n\n    pub fn system_prompt_prefix(&self) -> Option<&str> {\n        self.data.system_prompt_prefix.as_deref()\n    }\n\n    pub fn max_tokens_per_chunk(&self) -> Option<usize> {\n        self.data.max_tokens_per_chunk\n    }\n\n    pub fn default_chunk_size(&self) -> usize {\n        self.data.default_chunk_size.unwrap_or(1000)\n    }\n\n    pub fn max_batch_size(&self) -> Option<usize> {\n        self.data.max_batch_size\n    }\n\n    pub fn max_tokens_param(&self) -> Option<isize> {\n        if self.data.require_max_tokens {\n            self.data.max_output_tokens\n        } else {\n            None\n        }\n    }\n\n    pub fn set_max_tokens(\n        &mut self,\n        max_output_tokens: Option<isize>,\n        require_max_tokens: bool,\n    ) -> &mut Self {\n        match max_output_tokens {\n            None | Some(0) => self.data.max_output_tokens = None,\n            _ => self.data.max_output_tokens = max_output_tokens,\n        }\n        self.data.require_max_tokens = require_max_tokens;\n        self\n    }\n\n    pub fn messages_tokens(&self, messages: &[Message]) -> usize {\n        let messages_len = messages.len();\n        messages\n            .iter()\n            .enumerate()\n            .map(|(i, v)| match &v.content {\n                MessageContent::Text(text) => {\n                    if v.role.is_assistant() && i != messages_len - 1 {\n                        estimate_token_length(&strip_think_tag(text))\n                    } else {\n                        estimate_token_length(text)\n                    }\n                }\n                MessageContent::Array(list) => list\n                    .iter()\n                    .map(|v| match v {\n                        MessageContentPart::Text { text } => estimate_token_length(text),\n                        MessageContentPart::ImageUrl { .. } => 0,\n                    })\n                    .sum(),\n                MessageContent::ToolCalls(MessageContentToolCalls {\n                    tool_results, text, ..\n                }) => {\n                    estimate_token_length(text)\n                        + tool_results\n                            .iter()\n                            .map(|v| {\n                                serde_json::to_string(v)\n                                    .map(|v| estimate_token_length(&v))\n                                    .unwrap_or_default()\n                            })\n                            .sum::<usize>()\n                }\n            })\n            .sum()\n    }\n\n    pub fn total_tokens(&self, messages: &[Message]) -> usize {\n        if messages.is_empty() {\n            return 0;\n        }\n        let num_messages = messages.len();\n        let message_tokens = self.messages_tokens(messages);\n        if messages[num_messages - 1].role.is_user() {\n            num_messages * PER_MESSAGES_TOKENS + message_tokens\n        } else {\n            (num_messages - 1) * PER_MESSAGES_TOKENS + message_tokens\n        }\n    }\n\n    pub fn guard_max_input_tokens(&self, messages: &[Message]) -> Result<()> {\n        let total_tokens = self.total_tokens(messages) + BASIS_TOKENS;\n        if let Some(max_input_tokens) = self.data.max_input_tokens {\n            if total_tokens >= max_input_tokens {\n                bail!(\"Exceed max_input_tokens limit\")\n            }\n        }\n        Ok(())\n    }\n}\n\n#[derive(Debug, Clone, Default, Serialize, Deserialize)]\npub struct ModelData {\n    pub name: String,\n    #[serde(default = \"default_model_type\", rename = \"type\")]\n    pub model_type: String,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub real_name: Option<String>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub max_input_tokens: Option<usize>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub input_price: Option<f64>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub output_price: Option<f64>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub patch: Option<Value>,\n\n    // chat-only properties\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub max_output_tokens: Option<isize>,\n    #[serde(default, skip_serializing_if = \"std::ops::Not::not\")]\n    pub require_max_tokens: bool,\n    #[serde(default, skip_serializing_if = \"std::ops::Not::not\")]\n    pub supports_vision: bool,\n    #[serde(default, skip_serializing_if = \"std::ops::Not::not\")]\n    pub supports_function_calling: bool,\n    #[serde(default, skip_serializing_if = \"std::ops::Not::not\")]\n    no_stream: bool,\n    #[serde(default, skip_serializing_if = \"std::ops::Not::not\")]\n    no_system_message: bool,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    system_prompt_prefix: Option<String>,\n\n    // embedding-only properties\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub max_tokens_per_chunk: Option<usize>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub default_chunk_size: Option<usize>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub max_batch_size: Option<usize>,\n}\n\nimpl ModelData {\n    pub fn new(name: &str) -> Self {\n        Self {\n            name: name.to_string(),\n            model_type: default_model_type(),\n            ..Default::default()\n        }\n    }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ProviderModels {\n    pub provider: String,\n    pub models: Vec<ModelData>,\n}\n\nfn default_model_type() -> String {\n    \"chat\".into()\n}\n\n#[derive(Debug, Clone, Copy, PartialEq)]\npub enum ModelType {\n    Chat,\n    Embedding,\n    Reranker,\n}\n\nimpl Display for ModelType {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        match self {\n            ModelType::Chat => write!(f, \"chat\"),\n            ModelType::Embedding => write!(f, \"embedding\"),\n            ModelType::Reranker => write!(f, \"reranker\"),\n        }\n    }\n}\n\nimpl ModelType {\n    pub fn can_create_from_name(self) -> bool {\n        match self {\n            ModelType::Chat => true,\n            ModelType::Embedding => false,\n            ModelType::Reranker => true,\n        }\n    }\n\n    pub fn api_name(self) -> &'static str {\n        match self {\n            ModelType::Chat => \"chat_completions\",\n            ModelType::Embedding => \"embeddings\",\n            ModelType::Reranker => \"rerank\",\n        }\n    }\n\n    pub fn extract_patch(self, patch: &RequestPatch) -> Option<&ApiPatch> {\n        match self {\n            ModelType::Chat => patch.chat_completions.as_ref(),\n            ModelType::Embedding => patch.embeddings.as_ref(),\n            ModelType::Reranker => patch.rerank.as_ref(),\n        }\n    }\n}\n\nfn stringify_option_value<T>(value: &Option<T>) -> String\nwhere\n    T: std::fmt::Display,\n{\n    match value {\n        Some(value) => value.to_string(),\n        None => \"-\".to_string(),\n    }\n}\n"
  },
  {
    "path": "src/client/openai.rs",
    "content": "use super::*;\n\nuse crate::utils::strip_think_tag;\n\nuse anyhow::{bail, Context, Result};\nuse reqwest::RequestBuilder;\nuse serde::Deserialize;\nuse serde_json::{json, Value};\n\nconst API_BASE: &str = \"https://api.openai.com/v1\";\n\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct OpenAIConfig {\n    pub name: Option<String>,\n    pub api_key: Option<String>,\n    pub api_base: Option<String>,\n    pub organization_id: Option<String>,\n    #[serde(default)]\n    pub models: Vec<ModelData>,\n    pub patch: Option<RequestPatch>,\n    pub extra: Option<ExtraConfig>,\n}\n\nimpl OpenAIClient {\n    config_get_fn!(api_key, get_api_key);\n    config_get_fn!(api_base, get_api_base);\n\n    pub const PROMPTS: [PromptAction<'static>; 1] = [(\"api_key\", \"API Key\", None)];\n}\n\nimpl_client_trait!(\n    OpenAIClient,\n    (\n        prepare_chat_completions,\n        openai_chat_completions,\n        openai_chat_completions_streaming\n    ),\n    (prepare_embeddings, openai_embeddings),\n    (noop_prepare_rerank, noop_rerank),\n);\n\nfn prepare_chat_completions(\n    self_: &OpenAIClient,\n    data: ChatCompletionsData,\n) -> Result<RequestData> {\n    let api_key = self_.get_api_key()?;\n    let api_base = self_\n        .get_api_base()\n        .unwrap_or_else(|_| API_BASE.to_string());\n\n    let url = format!(\"{}/chat/completions\", api_base.trim_end_matches('/'));\n\n    let body = openai_build_chat_completions_body(data, &self_.model);\n\n    let mut request_data = RequestData::new(url, body);\n\n    request_data.bearer_auth(api_key);\n    if let Some(organization_id) = &self_.config.organization_id {\n        request_data.header(\"OpenAI-Organization\", organization_id);\n    }\n\n    Ok(request_data)\n}\n\nfn prepare_embeddings(self_: &OpenAIClient, data: &EmbeddingsData) -> Result<RequestData> {\n    let api_key = self_.get_api_key()?;\n    let api_base = self_\n        .get_api_base()\n        .unwrap_or_else(|_| API_BASE.to_string());\n\n    let url = format!(\"{api_base}/embeddings\");\n\n    let body = openai_build_embeddings_body(data, &self_.model);\n\n    let mut request_data = RequestData::new(url, body);\n\n    request_data.bearer_auth(api_key);\n    if let Some(organization_id) = &self_.config.organization_id {\n        request_data.header(\"OpenAI-Organization\", organization_id);\n    }\n\n    Ok(request_data)\n}\n\npub async fn openai_chat_completions(\n    builder: RequestBuilder,\n    _model: &Model,\n) -> Result<ChatCompletionsOutput> {\n    let res = builder.send().await?;\n    let status = res.status();\n    let data: Value = res.json().await?;\n    if !status.is_success() {\n        catch_error(&data, status.as_u16())?;\n    }\n\n    debug!(\"non-stream-data: {data}\");\n    openai_extract_chat_completions(&data)\n}\n\npub async fn openai_chat_completions_streaming(\n    builder: RequestBuilder,\n    handler: &mut SseHandler,\n    _model: &Model,\n) -> Result<()> {\n    let mut call_id = String::new();\n    let mut function_name = String::new();\n    let mut function_arguments = String::new();\n    let mut function_id = String::new();\n    let mut reasoning_state = 0;\n    let handle = |message: SseMmessage| -> Result<bool> {\n        if message.data == \"[DONE]\" {\n            if !function_name.is_empty() {\n                if function_arguments.is_empty() {\n                    function_arguments = String::from(\"{}\");\n                }\n                let arguments: Value = function_arguments.parse().with_context(|| {\n                    format!(\"Tool call '{function_name}' have non-JSON arguments '{function_arguments}'\")\n                })?;\n                handler.tool_call(ToolCall::new(\n                    function_name.clone(),\n                    arguments,\n                    normalize_function_id(&function_id),\n                ))?;\n            }\n            return Ok(true);\n        }\n        let data: Value = serde_json::from_str(&message.data)?;\n        debug!(\"stream-data: {data}\");\n        if let Some(text) = data[\"choices\"][0][\"delta\"][\"content\"]\n            .as_str()\n            .filter(|v| !v.is_empty())\n        {\n            if reasoning_state == 1 {\n                handler.text(\"\\n</think>\\n\\n\")?;\n                reasoning_state = 0;\n            }\n            handler.text(text)?;\n        } else if let Some(text) = data[\"choices\"][0][\"delta\"][\"reasoning_content\"]\n            .as_str()\n            .or_else(|| data[\"choices\"][0][\"delta\"][\"reasoning\"].as_str())\n            .filter(|v| !v.is_empty())\n        {\n            if reasoning_state == 0 {\n                handler.text(\"<think>\\n\")?;\n                reasoning_state = 1;\n            }\n            handler.text(text)?;\n        }\n        if let (Some(function), index, id) = (\n            data[\"choices\"][0][\"delta\"][\"tool_calls\"][0][\"function\"].as_object(),\n            data[\"choices\"][0][\"delta\"][\"tool_calls\"][0][\"index\"].as_u64(),\n            data[\"choices\"][0][\"delta\"][\"tool_calls\"][0][\"id\"]\n                .as_str()\n                .filter(|v| !v.is_empty()),\n        ) {\n            if reasoning_state == 1 {\n                handler.text(\"\\n</think>\\n\\n\")?;\n                reasoning_state = 0;\n            }\n            let maybe_call_id = format!(\"{}/{}\", id.unwrap_or_default(), index.unwrap_or_default());\n            if maybe_call_id != call_id && maybe_call_id.len() >= call_id.len() {\n                if !function_name.is_empty() {\n                    if function_arguments.is_empty() {\n                        function_arguments = String::from(\"{}\");\n                    }\n                    let arguments: Value = function_arguments.parse().with_context(|| {\n                        format!(\"Tool call '{function_name}' have non-JSON arguments '{function_arguments}'\")\n                    })?;\n                    handler.tool_call(ToolCall::new(\n                        function_name.clone(),\n                        arguments,\n                        normalize_function_id(&function_id),\n                    ))?;\n                }\n                function_name.clear();\n                function_arguments.clear();\n                function_id.clear();\n                call_id = maybe_call_id;\n            }\n            if let Some(name) = function.get(\"name\").and_then(|v| v.as_str()) {\n                if name.starts_with(&function_name) {\n                    function_name = name.to_string();\n                } else {\n                    function_name.push_str(name);\n                }\n            }\n            if let Some(arguments) = function.get(\"arguments\").and_then(|v| v.as_str()) {\n                function_arguments.push_str(arguments);\n            }\n            if let Some(id) = id {\n                function_id = id.to_string();\n            }\n        }\n        Ok(false)\n    };\n\n    sse_stream(builder, handle).await\n}\n\npub async fn openai_embeddings(\n    builder: RequestBuilder,\n    _model: &Model,\n) -> Result<EmbeddingsOutput> {\n    let res = builder.send().await?;\n    let status = res.status();\n    let data: Value = res.json().await?;\n    if !status.is_success() {\n        catch_error(&data, status.as_u16())?;\n    }\n    let res_body: EmbeddingsResBody =\n        serde_json::from_value(data).context(\"Invalid embeddings data\")?;\n    let output = res_body.data.into_iter().map(|v| v.embedding).collect();\n    Ok(output)\n}\n\n#[derive(Deserialize)]\nstruct EmbeddingsResBody {\n    data: Vec<EmbeddingsResBodyEmbedding>,\n}\n\n#[derive(Deserialize)]\nstruct EmbeddingsResBodyEmbedding {\n    embedding: Vec<f32>,\n}\n\npub fn openai_build_chat_completions_body(data: ChatCompletionsData, model: &Model) -> Value {\n    let ChatCompletionsData {\n        messages,\n        temperature,\n        top_p,\n        functions,\n        stream,\n    } = data;\n\n    let messages_len = messages.len();\n    let messages: Vec<Value> = messages\n        .into_iter()\n        .enumerate()\n        .flat_map(|(i, message)| {\n            let Message { role, content } = message;\n            match content {\n                MessageContent::ToolCalls(MessageContentToolCalls {\n                    tool_results,\n                    text: _,\n                    sequence,\n                }) => {\n                    if !sequence {\n                        let tool_calls: Vec<_> = tool_results\n                            .iter()\n                            .map(|tool_result| {\n                                json!({\n                                    \"id\": tool_result.call.id,\n                                    \"type\": \"function\",\n                                    \"function\": {\n                                        \"name\": tool_result.call.name,\n                                        \"arguments\": tool_result.call.arguments.to_string(),\n                                    },\n                                })\n                            })\n                            .collect();\n                        let mut messages = vec![\n                            json!({ \"role\": MessageRole::Assistant, \"tool_calls\": tool_calls }),\n                        ];\n                        for tool_result in tool_results {\n                            messages.push(json!({\n                                \"role\": \"tool\",\n                                \"content\": tool_result.output.to_string(),\n                                \"tool_call_id\": tool_result.call.id,\n                            }));\n                        }\n                        messages\n                    } else {\n                        tool_results.into_iter().flat_map(|tool_result| {\n                            vec![\n                                json!({\n                                    \"role\": MessageRole::Assistant,\n                                    \"tool_calls\": [\n                                        {\n                                            \"id\": tool_result.call.id,\n                                            \"type\": \"function\",\n                                            \"function\": {\n                                                \"name\": tool_result.call.name,\n                                                \"arguments\": tool_result.call.arguments.to_string(),\n                                            },\n                                        }\n                                    ]\n                                }),\n                                json!({\n                                    \"role\": \"tool\",\n                                    \"content\": tool_result.output.to_string(),\n                                    \"tool_call_id\": tool_result.call.id,\n                                })\n                            ]\n\n                        }).collect()\n                    }\n                }\n                MessageContent::Text(text) if role.is_assistant() && i != messages_len - 1 => {\n                    vec![json!({ \"role\": role, \"content\": strip_think_tag(&text) }\n                    )]\n                }\n                _ => vec![json!({ \"role\": role, \"content\": content })],\n            }\n        })\n        .collect();\n\n    let mut body = json!({\n        \"model\": &model.real_name(),\n        \"messages\": messages,\n    });\n\n    if let Some(v) = model.max_tokens_param() {\n        if model\n            .patch()\n            .and_then(|v| v.get(\"body\").and_then(|v| v.get(\"max_tokens\")))\n            == Some(&Value::Null)\n        {\n            body[\"max_completion_tokens\"] = v.into();\n        } else {\n            body[\"max_tokens\"] = v.into();\n        }\n    }\n    if let Some(v) = temperature {\n        body[\"temperature\"] = v.into();\n    }\n    if let Some(v) = top_p {\n        body[\"top_p\"] = v.into();\n    }\n    if stream {\n        body[\"stream\"] = true.into();\n    }\n    if let Some(functions) = functions {\n        body[\"tools\"] = functions\n            .iter()\n            .map(|v| {\n                json!({\n                    \"type\": \"function\",\n                    \"function\": v,\n                })\n            })\n            .collect();\n    }\n    body\n}\n\npub fn openai_build_embeddings_body(data: &EmbeddingsData, model: &Model) -> Value {\n    json!({\n        \"input\": data.texts,\n        \"model\": model.real_name()\n    })\n}\n\npub fn openai_extract_chat_completions(data: &Value) -> Result<ChatCompletionsOutput> {\n    let text = data[\"choices\"][0][\"message\"][\"content\"]\n        .as_str()\n        .unwrap_or_default();\n\n    let reasoning = data[\"choices\"][0][\"message\"][\"reasoning_content\"]\n        .as_str()\n        .or_else(|| data[\"choices\"][0][\"message\"][\"reasoning\"].as_str())\n        .unwrap_or_default()\n        .trim();\n\n    let mut tool_calls = vec![];\n    if let Some(calls) = data[\"choices\"][0][\"message\"][\"tool_calls\"].as_array() {\n        for call in calls {\n            if let (Some(name), Some(arguments), Some(id)) = (\n                call[\"function\"][\"name\"].as_str(),\n                call[\"function\"][\"arguments\"].as_str(),\n                call[\"id\"].as_str(),\n            ) {\n                let arguments: Value = arguments.parse().with_context(|| {\n                    format!(\"Tool call '{name}' have non-JSON arguments '{arguments}'\")\n                })?;\n                tool_calls.push(ToolCall::new(\n                    name.to_string(),\n                    arguments,\n                    Some(id.to_string()),\n                ));\n            }\n        }\n    };\n\n    if text.is_empty() && tool_calls.is_empty() {\n        bail!(\"Invalid response data: {data}\");\n    }\n    let text = if !reasoning.is_empty() {\n        format!(\"<think>\\n{reasoning}\\n</think>\\n\\n{text}\")\n    } else {\n        text.to_string()\n    };\n    let output = ChatCompletionsOutput {\n        text,\n        tool_calls,\n        id: data[\"id\"].as_str().map(|v| v.to_string()),\n        input_tokens: data[\"usage\"][\"prompt_tokens\"].as_u64(),\n        output_tokens: data[\"usage\"][\"completion_tokens\"].as_u64(),\n    };\n    Ok(output)\n}\n\nfn normalize_function_id(value: &str) -> Option<String> {\n    if value.is_empty() {\n        None\n    } else {\n        Some(value.to_string())\n    }\n}\n"
  },
  {
    "path": "src/client/openai_compatible.rs",
    "content": "use super::openai::*;\nuse super::*;\n\nuse anyhow::{Context, Result};\nuse reqwest::RequestBuilder;\nuse serde::Deserialize;\nuse serde_json::{json, Value};\n\n#[derive(Debug, Clone, Deserialize)]\npub struct OpenAICompatibleConfig {\n    pub name: Option<String>,\n    pub api_base: Option<String>,\n    pub api_key: Option<String>,\n    #[serde(default)]\n    pub models: Vec<ModelData>,\n    pub patch: Option<RequestPatch>,\n    pub extra: Option<ExtraConfig>,\n}\n\nimpl OpenAICompatibleClient {\n    config_get_fn!(api_base, get_api_base);\n    config_get_fn!(api_key, get_api_key);\n\n    pub const PROMPTS: [PromptAction<'static>; 0] = [];\n}\n\nimpl_client_trait!(\n    OpenAICompatibleClient,\n    (\n        prepare_chat_completions,\n        openai_chat_completions,\n        openai_chat_completions_streaming\n    ),\n    (prepare_embeddings, openai_embeddings),\n    (prepare_rerank, generic_rerank),\n);\n\nfn prepare_chat_completions(\n    self_: &OpenAICompatibleClient,\n    data: ChatCompletionsData,\n) -> Result<RequestData> {\n    let api_key = self_.get_api_key().ok();\n    let api_base = get_api_base_ext(self_)?;\n\n    let url = format!(\"{api_base}/chat/completions\");\n\n    let body = openai_build_chat_completions_body(data, &self_.model);\n\n    let mut request_data = RequestData::new(url, body);\n\n    if let Some(api_key) = api_key {\n        request_data.bearer_auth(api_key);\n    }\n\n    Ok(request_data)\n}\n\nfn prepare_embeddings(\n    self_: &OpenAICompatibleClient,\n    data: &EmbeddingsData,\n) -> Result<RequestData> {\n    let api_key = self_.get_api_key().ok();\n    let api_base = get_api_base_ext(self_)?;\n\n    let url = format!(\"{api_base}/embeddings\");\n\n    let body = openai_build_embeddings_body(data, &self_.model);\n\n    let mut request_data = RequestData::new(url, body);\n\n    if let Some(api_key) = api_key {\n        request_data.bearer_auth(api_key);\n    }\n\n    Ok(request_data)\n}\n\nfn prepare_rerank(self_: &OpenAICompatibleClient, data: &RerankData) -> Result<RequestData> {\n    let api_key = self_.get_api_key().ok();\n    let api_base = get_api_base_ext(self_)?;\n\n    let url = if self_.name().starts_with(\"ernie\") {\n        format!(\"{api_base}/rerankers\")\n    } else {\n        format!(\"{api_base}/rerank\")\n    };\n\n    let body = generic_build_rerank_body(data, &self_.model);\n\n    let mut request_data = RequestData::new(url, body);\n\n    if let Some(api_key) = api_key {\n        request_data.bearer_auth(api_key);\n    }\n\n    Ok(request_data)\n}\n\nfn get_api_base_ext(self_: &OpenAICompatibleClient) -> Result<String> {\n    let api_base = match self_.get_api_base() {\n        Ok(v) => v,\n        Err(err) => {\n            match OPENAI_COMPATIBLE_PROVIDERS\n                .into_iter()\n                .find_map(|(name, api_base)| {\n                    if name == self_.model.client_name() {\n                        Some(api_base.to_string())\n                    } else {\n                        None\n                    }\n                }) {\n                Some(v) => v,\n                None => return Err(err),\n            }\n        }\n    };\n    Ok(api_base.trim_end_matches('/').to_string())\n}\n\npub async fn generic_rerank(builder: RequestBuilder, _model: &Model) -> Result<RerankOutput> {\n    let res = builder.send().await?;\n    let status = res.status();\n    let mut data: Value = res.json().await?;\n    if !status.is_success() {\n        catch_error(&data, status.as_u16())?;\n    }\n    if data.get(\"results\").is_none() && data.get(\"data\").is_some() {\n        if let Some(data_obj) = data.as_object_mut() {\n            if let Some(value) = data_obj.remove(\"data\") {\n                data_obj.insert(\"results\".to_string(), value);\n            }\n        }\n    }\n    let res_body: GenericRerankResBody =\n        serde_json::from_value(data).context(\"Invalid rerank data\")?;\n    Ok(res_body.results)\n}\n\n#[derive(Deserialize)]\npub struct GenericRerankResBody {\n    pub results: RerankOutput,\n}\n\npub fn generic_build_rerank_body(data: &RerankData, model: &Model) -> Value {\n    let RerankData {\n        query,\n        documents,\n        top_n,\n    } = data;\n\n    let mut body = json!({\n        \"model\": model.real_name(),\n        \"query\": query,\n        \"documents\": documents,\n    });\n    if model.client_name().starts_with(\"voyageai\") {\n        body[\"top_k\"] = (*top_n).into()\n    } else {\n        body[\"top_n\"] = (*top_n).into()\n    }\n    body\n}\n"
  },
  {
    "path": "src/client/stream.rs",
    "content": "use super::{catch_error, ToolCall};\nuse crate::utils::AbortSignal;\n\nuse anyhow::{anyhow, bail, Context, Result};\nuse futures_util::{Stream, StreamExt};\nuse reqwest::RequestBuilder;\nuse reqwest_eventsource::{Error as EventSourceError, Event, RequestBuilderExt};\nuse serde_json::Value;\nuse tokio::sync::mpsc::UnboundedSender;\n\npub struct SseHandler {\n    sender: UnboundedSender<SseEvent>,\n    abort_signal: AbortSignal,\n    buffer: String,\n    tool_calls: Vec<ToolCall>,\n}\n\nimpl SseHandler {\n    pub fn new(sender: UnboundedSender<SseEvent>, abort_signal: AbortSignal) -> Self {\n        Self {\n            sender,\n            abort_signal,\n            buffer: String::new(),\n            tool_calls: Vec::new(),\n        }\n    }\n\n    pub fn text(&mut self, text: &str) -> Result<()> {\n        // debug!(\"HandleText: {}\", text);\n        if text.is_empty() {\n            return Ok(());\n        }\n        self.buffer.push_str(text);\n        let ret = self\n            .sender\n            .send(SseEvent::Text(text.to_string()))\n            .with_context(|| \"Failed to send SseEvent:Text\");\n        if let Err(err) = ret {\n            if self.abort_signal.aborted() {\n                return Ok(());\n            }\n            return Err(err);\n        }\n        Ok(())\n    }\n\n    pub fn done(&mut self) {\n        // debug!(\"HandleDone\");\n        let ret = self.sender.send(SseEvent::Done);\n        if ret.is_err() {\n            if self.abort_signal.aborted() {\n                return;\n            }\n            warn!(\"Failed to send SseEvent:Done\");\n        }\n    }\n\n    pub fn tool_call(&mut self, call: ToolCall) -> Result<()> {\n        // debug!(\"HandleCall: {:?}\", call);\n        self.tool_calls.push(call);\n        Ok(())\n    }\n\n    pub fn abort(&self) -> AbortSignal {\n        self.abort_signal.clone()\n    }\n\n    pub fn tool_calls(&self) -> &[ToolCall] {\n        &self.tool_calls\n    }\n\n    pub fn take(self) -> (String, Vec<ToolCall>) {\n        let Self {\n            buffer, tool_calls, ..\n        } = self;\n        (buffer, tool_calls)\n    }\n}\n\n#[derive(Debug)]\npub enum SseEvent {\n    Text(String),\n    Done,\n}\n\n#[derive(Debug)]\npub struct SseMmessage {\n    #[allow(unused)]\n    pub event: String,\n    pub data: String,\n}\n\npub async fn sse_stream<F>(builder: RequestBuilder, mut handle: F) -> Result<()>\nwhere\n    F: FnMut(SseMmessage) -> Result<bool>,\n{\n    let mut es = builder.eventsource()?;\n    while let Some(event) = es.next().await {\n        match event {\n            Ok(Event::Open) => {}\n            Ok(Event::Message(message)) => {\n                let message = SseMmessage {\n                    event: message.event,\n                    data: message.data,\n                };\n                if handle(message)? {\n                    break;\n                }\n            }\n            Err(err) => {\n                match err {\n                    EventSourceError::StreamEnded => {}\n                    EventSourceError::InvalidStatusCode(status, res) => {\n                        let text = res.text().await?;\n                        let data: Value = match text.parse() {\n                            Ok(data) => data,\n                            Err(_) => {\n                                bail!(\n                                    \"Invalid response data: {text} (status: {})\",\n                                    status.as_u16()\n                                );\n                            }\n                        };\n                        catch_error(&data, status.as_u16())?;\n                    }\n                    EventSourceError::InvalidContentType(header_value, res) => {\n                        let text = res.text().await?;\n                        bail!(\n                            \"Invalid response event-stream. content-type: {}, data: {text}\",\n                            header_value.to_str().unwrap_or_default()\n                        );\n                    }\n                    _ => {\n                        bail!(\"{}\", err);\n                    }\n                }\n                es.close();\n            }\n        }\n    }\n    Ok(())\n}\n\npub async fn json_stream<S, F, E>(mut stream: S, mut handle: F) -> Result<()>\nwhere\n    S: Stream<Item = Result<bytes::Bytes, E>> + Unpin,\n    F: FnMut(&str) -> Result<()>,\n    E: std::error::Error,\n{\n    let mut parser = JsonStreamParser::default();\n    let mut unparsed_bytes = vec![];\n    while let Some(chunk_bytes) = stream.next().await {\n        let chunk_bytes =\n            chunk_bytes.map_err(|err| anyhow!(\"Failed to read json stream, {err}\"))?;\n        unparsed_bytes.extend(chunk_bytes);\n        match std::str::from_utf8(&unparsed_bytes) {\n            Ok(text) => {\n                parser.process(text, &mut handle)?;\n                unparsed_bytes.clear();\n            }\n            Err(_) => {\n                continue;\n            }\n        }\n    }\n    if !unparsed_bytes.is_empty() {\n        let text = std::str::from_utf8(&unparsed_bytes)?;\n        parser.process(text, &mut handle)?;\n    }\n\n    Ok(())\n}\n\n#[derive(Debug, Default)]\nstruct JsonStreamParser {\n    buffer: Vec<char>,\n    cursor: usize,\n    start: Option<usize>,\n    balances: Vec<char>,\n    quoting: bool,\n    escape: bool,\n}\n\nimpl JsonStreamParser {\n    fn process<F>(&mut self, text: &str, handle: &mut F) -> Result<()>\n    where\n        F: FnMut(&str) -> Result<()>,\n    {\n        self.buffer.extend(text.chars());\n\n        for i in self.cursor..self.buffer.len() {\n            let ch = self.buffer[i];\n            if self.quoting {\n                if ch == '\\\\' {\n                    self.escape = !self.escape;\n                } else {\n                    if !self.escape && ch == '\"' {\n                        self.quoting = false;\n                    }\n                    self.escape = false;\n                }\n                continue;\n            }\n            match ch {\n                '\"' => {\n                    self.quoting = true;\n                    self.escape = false;\n                }\n                '{' => {\n                    if self.balances.is_empty() {\n                        self.start = Some(i);\n                    }\n                    self.balances.push(ch);\n                }\n                '[' => {\n                    if self.start.is_some() {\n                        self.balances.push(ch);\n                    }\n                }\n                '}' => {\n                    self.balances.pop();\n                    if self.balances.is_empty() {\n                        if let Some(start) = self.start.take() {\n                            let value: String = self.buffer[start..=i].iter().collect();\n                            handle(&value)?;\n                        }\n                    }\n                }\n                ']' => {\n                    self.balances.pop();\n                }\n                _ => {}\n            }\n        }\n        self.cursor = self.buffer.len();\n        Ok(())\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    use bytes::Bytes;\n    use futures_util::stream;\n    use rand::Rng;\n\n    fn split_chunks(text: &str) -> Vec<Vec<u8>> {\n        let mut rng = rand::rng();\n        let len = text.len();\n        let cut1 = rng.random_range(1..len - 1);\n        let cut2 = rng.random_range(cut1 + 1..len);\n        let chunk1 = text.as_bytes()[..cut1].to_vec();\n        let chunk2 = text.as_bytes()[cut1..cut2].to_vec();\n        let chunk3 = text.as_bytes()[cut2..].to_vec();\n        vec![chunk1, chunk2, chunk3]\n    }\n\n    macro_rules! assert_json_stream {\n        ($input:expr, $output:expr) => {\n            let chunks: Vec<_> = split_chunks($input)\n                .into_iter()\n                .map(|chunk| Ok::<_, std::convert::Infallible>(Bytes::from(chunk)))\n                .collect();\n            let stream = stream::iter(chunks);\n            let mut output = vec![];\n            let ret = json_stream(stream, |data| {\n                output.push(data.to_string());\n                Ok(())\n            })\n            .await;\n            assert!(ret.is_ok());\n            assert_eq!($output.replace(\"\\r\\n\", \"\\n\"), output.join(\"\\n\"))\n        };\n    }\n\n    #[tokio::test]\n    async fn test_json_stream_ndjson() {\n        let data = r#\"{\"key\": \"value\"}\n{\"key\": \"value2\"}\n{\"key\": \"value3\"}\"#;\n        assert_json_stream!(data, data);\n    }\n\n    #[tokio::test]\n    async fn test_json_stream_array() {\n        let input = r#\"[\n{\"key\": \"value\"},\n{\"key\": \"value2\"},\n{\"key\": \"value3\"},\"#;\n        let output = r#\"{\"key\": \"value\"}\n{\"key\": \"value2\"}\n{\"key\": \"value3\"}\"#;\n        assert_json_stream!(input, output);\n    }\n}\n"
  },
  {
    "path": "src/client/vertexai.rs",
    "content": "use super::access_token::*;\nuse super::claude::*;\nuse super::openai::*;\nuse super::*;\n\nuse anyhow::{anyhow, bail, Context, Result};\nuse chrono::{Duration, Utc};\nuse reqwest::{Client as ReqwestClient, RequestBuilder};\nuse serde::Deserialize;\nuse serde_json::{json, Value};\nuse std::{path::PathBuf, str::FromStr};\n\n#[derive(Debug, Clone, Deserialize, Default)]\npub struct VertexAIConfig {\n    pub name: Option<String>,\n    pub project_id: Option<String>,\n    pub location: Option<String>,\n    pub adc_file: Option<String>,\n    #[serde(default)]\n    pub models: Vec<ModelData>,\n    pub patch: Option<RequestPatch>,\n    pub extra: Option<ExtraConfig>,\n}\n\nimpl VertexAIClient {\n    config_get_fn!(project_id, get_project_id);\n    config_get_fn!(location, get_location);\n\n    pub const PROMPTS: [PromptAction<'static>; 2] = [\n        (\"project_id\", \"Project ID\", None),\n        (\"location\", \"Location\", None),\n    ];\n}\n\n#[async_trait::async_trait]\nimpl Client for VertexAIClient {\n    client_common_fns!();\n\n    async fn chat_completions_inner(\n        &self,\n        client: &ReqwestClient,\n        data: ChatCompletionsData,\n    ) -> Result<ChatCompletionsOutput> {\n        prepare_gcloud_access_token(client, self.name(), &self.config.adc_file).await?;\n        let model = self.model();\n        let model_category = ModelCategory::from_str(model.real_name())?;\n        let request_data = prepare_chat_completions(self, data, &model_category)?;\n        let builder = self.request_builder(client, request_data);\n        match model_category {\n            ModelCategory::Gemini => gemini_chat_completions(builder, model).await,\n            ModelCategory::Claude => claude_chat_completions(builder, model).await,\n            ModelCategory::Mistral => openai_chat_completions(builder, model).await,\n        }\n    }\n\n    async fn chat_completions_streaming_inner(\n        &self,\n        client: &ReqwestClient,\n        handler: &mut SseHandler,\n        data: ChatCompletionsData,\n    ) -> Result<()> {\n        prepare_gcloud_access_token(client, self.name(), &self.config.adc_file).await?;\n        let model = self.model();\n        let model_category = ModelCategory::from_str(model.real_name())?;\n        let request_data = prepare_chat_completions(self, data, &model_category)?;\n        let builder = self.request_builder(client, request_data);\n        match model_category {\n            ModelCategory::Gemini => {\n                gemini_chat_completions_streaming(builder, handler, model).await\n            }\n            ModelCategory::Claude => {\n                claude_chat_completions_streaming(builder, handler, model).await\n            }\n            ModelCategory::Mistral => {\n                openai_chat_completions_streaming(builder, handler, model).await\n            }\n        }\n    }\n\n    async fn embeddings_inner(\n        &self,\n        client: &ReqwestClient,\n        data: &EmbeddingsData,\n    ) -> Result<Vec<Vec<f32>>> {\n        prepare_gcloud_access_token(client, self.name(), &self.config.adc_file).await?;\n        let request_data = prepare_embeddings(self, data)?;\n        let builder = self.request_builder(client, request_data);\n        embeddings(builder, self.model()).await\n    }\n}\n\nfn prepare_chat_completions(\n    self_: &VertexAIClient,\n    data: ChatCompletionsData,\n    model_category: &ModelCategory,\n) -> Result<RequestData> {\n    let project_id = self_.get_project_id()?;\n    let location = self_.get_location()?;\n    let access_token = get_access_token(self_.name())?;\n\n    let base_url = if location == \"global\" {\n        format!(\"https://aiplatform.googleapis.com/v1/projects/{project_id}/locations/global/publishers\")\n    } else {\n        format!(\"https://{location}-aiplatform.googleapis.com/v1/projects/{project_id}/locations/{location}/publishers\")\n    };\n\n    let model_name = self_.model.real_name();\n\n    let url = match model_category {\n        ModelCategory::Gemini => {\n            let func = match data.stream {\n                true => \"streamGenerateContent\",\n                false => \"generateContent\",\n            };\n            format!(\"{base_url}/google/models/{model_name}:{func}\")\n        }\n        ModelCategory::Claude => {\n            format!(\"{base_url}/anthropic/models/{model_name}:streamRawPredict\")\n        }\n        ModelCategory::Mistral => {\n            let func = match data.stream {\n                true => \"streamRawPredict\",\n                false => \"rawPredict\",\n            };\n            format!(\"{base_url}/mistralai/models/{model_name}:{func}\")\n        }\n    };\n\n    let body = match model_category {\n        ModelCategory::Gemini => gemini_build_chat_completions_body(data, &self_.model)?,\n        ModelCategory::Claude => {\n            let mut body = claude_build_chat_completions_body(data, &self_.model)?;\n            if let Some(body_obj) = body.as_object_mut() {\n                body_obj.remove(\"model\");\n            }\n            body[\"anthropic_version\"] = \"vertex-2023-10-16\".into();\n            body\n        }\n        ModelCategory::Mistral => {\n            let mut body = openai_build_chat_completions_body(data, &self_.model);\n            if let Some(body_obj) = body.as_object_mut() {\n                body_obj[\"model\"] = strip_model_version(self_.model.real_name()).into();\n            }\n            body\n        }\n    };\n\n    let mut request_data = RequestData::new(url, body);\n\n    request_data.bearer_auth(access_token);\n\n    Ok(request_data)\n}\n\nfn prepare_embeddings(self_: &VertexAIClient, data: &EmbeddingsData) -> Result<RequestData> {\n    let project_id = self_.get_project_id()?;\n    let location = self_.get_location()?;\n    let access_token = get_access_token(self_.name())?;\n\n    let base_url = if location == \"global\" {\n        format!(\"https://aiplatform.googleapis.com/v1/projects/{project_id}/locations/global/publishers\")\n    } else {\n        format!(\"https://{location}-aiplatform.googleapis.com/v1/projects/{project_id}/locations/{location}/publishers\")\n    };\n    let url = format!(\n        \"{base_url}/google/models/{}:predict\",\n        self_.model.real_name()\n    );\n\n    let instances: Vec<_> = data.texts.iter().map(|v| json!({\"content\": v})).collect();\n\n    let body = json!({\n        \"instances\": instances,\n    });\n\n    let mut request_data = RequestData::new(url, body);\n\n    request_data.bearer_auth(access_token);\n\n    Ok(request_data)\n}\n\npub async fn gemini_chat_completions(\n    builder: RequestBuilder,\n    _model: &Model,\n) -> Result<ChatCompletionsOutput> {\n    let res = builder.send().await?;\n    let status = res.status();\n    let data: Value = res.json().await?;\n    if !status.is_success() {\n        catch_error(&data, status.as_u16())?;\n    }\n    debug!(\"non-stream-data: {data}\");\n    gemini_extract_chat_completions_text(&data)\n}\n\npub async fn gemini_chat_completions_streaming(\n    builder: RequestBuilder,\n    handler: &mut SseHandler,\n    _model: &Model,\n) -> Result<()> {\n    let res = builder.send().await?;\n    let status = res.status();\n    if !status.is_success() {\n        let data: Value = res.json().await?;\n        catch_error(&data, status.as_u16())?;\n    } else {\n        let handle = |value: &str| -> Result<()> {\n            let data: Value = serde_json::from_str(value)?;\n            debug!(\"stream-data: {data}\");\n            if let Some(parts) = data[\"candidates\"][0][\"content\"][\"parts\"].as_array() {\n                for (i, part) in parts.iter().enumerate() {\n                    if let Some(text) = part[\"text\"].as_str() {\n                        if i > 0 {\n                            handler.text(\"\\n\\n\")?;\n                        }\n                        handler.text(text)?;\n                    } else if let (Some(name), Some(args)) = (\n                        part[\"functionCall\"][\"name\"].as_str(),\n                        part[\"functionCall\"][\"args\"].as_object(),\n                    ) {\n                        handler.tool_call(ToolCall::new(name.to_string(), json!(args), None))?;\n                    }\n                }\n            } else if let Some(\"SAFETY\") = data[\"promptFeedback\"][\"blockReason\"]\n                .as_str()\n                .or_else(|| data[\"candidates\"][0][\"finishReason\"].as_str())\n            {\n                bail!(\"Blocked due to safety\")\n            }\n\n            Ok(())\n        };\n        json_stream(res.bytes_stream(), handle).await?;\n    }\n    Ok(())\n}\n\nasync fn embeddings(builder: RequestBuilder, _model: &Model) -> Result<EmbeddingsOutput> {\n    let res = builder.send().await?;\n    let status = res.status();\n    let data: Value = res.json().await?;\n    if !status.is_success() {\n        catch_error(&data, status.as_u16())?;\n    }\n    let res_body: EmbeddingsResBody =\n        serde_json::from_value(data).context(\"Invalid embeddings data\")?;\n    let output = res_body\n        .predictions\n        .into_iter()\n        .map(|v| v.embeddings.values)\n        .collect();\n    Ok(output)\n}\n\n#[derive(Deserialize)]\nstruct EmbeddingsResBody {\n    predictions: Vec<EmbeddingsResBodyPrediction>,\n}\n\n#[derive(Deserialize)]\nstruct EmbeddingsResBodyPrediction {\n    embeddings: EmbeddingsResBodyPredictionEmbeddings,\n}\n\n#[derive(Deserialize)]\nstruct EmbeddingsResBodyPredictionEmbeddings {\n    values: Vec<f32>,\n}\n\nfn gemini_extract_chat_completions_text(data: &Value) -> Result<ChatCompletionsOutput> {\n    let mut text_parts = vec![];\n    let mut tool_calls = vec![];\n    if let Some(parts) = data[\"candidates\"][0][\"content\"][\"parts\"].as_array() {\n        for part in parts {\n            if let Some(text) = part[\"text\"].as_str() {\n                text_parts.push(text);\n            }\n            if let (Some(name), Some(args)) = (\n                part[\"functionCall\"][\"name\"].as_str(),\n                part[\"functionCall\"][\"args\"].as_object(),\n            ) {\n                tool_calls.push(ToolCall::new(name.to_string(), json!(args), None));\n            }\n        }\n    }\n\n    let text = text_parts.join(\"\\n\\n\");\n    if text.is_empty() && tool_calls.is_empty() {\n        if let Some(\"SAFETY\") = data[\"promptFeedback\"][\"blockReason\"]\n            .as_str()\n            .or_else(|| data[\"candidates\"][0][\"finishReason\"].as_str())\n        {\n            bail!(\"Blocked due to safety\")\n        } else {\n            bail!(\"Invalid response data: {data}\");\n        }\n    }\n    let output = ChatCompletionsOutput {\n        text,\n        tool_calls,\n        id: None,\n        input_tokens: data[\"usageMetadata\"][\"promptTokenCount\"].as_u64(),\n        output_tokens: data[\"usageMetadata\"][\"candidatesTokenCount\"].as_u64(),\n    };\n    Ok(output)\n}\n\npub fn gemini_build_chat_completions_body(\n    data: ChatCompletionsData,\n    model: &Model,\n) -> Result<Value> {\n    let ChatCompletionsData {\n        mut messages,\n        temperature,\n        top_p,\n        functions,\n        stream: _,\n    } = data;\n\n    let system_message = extract_system_message(&mut messages);\n\n    let mut network_image_urls = vec![];\n    let contents: Vec<Value> = messages\n        .into_iter()\n        .flat_map(|message| {\n            let Message { role, content } = message;\n            let role = match role {\n                MessageRole::User => \"user\",\n                _ => \"model\",\n            };\n               match content {\n                    MessageContent::Text(text) => vec![json!({\n                        \"role\": role,\n                        \"parts\": [{ \"text\": text }]\n                    })],\n                    MessageContent::Array(list) => {\n                        let parts: Vec<Value> = list\n                            .into_iter()\n                            .map(|item| match item {\n                                MessageContentPart::Text { text } => json!({\"text\": text}),\n                                MessageContentPart::ImageUrl { image_url: ImageUrl { url } } => {\n                                    if let Some((mime_type, data)) = url.strip_prefix(\"data:\").and_then(|v| v.split_once(\";base64,\")) {\n                                        json!({ \"inline_data\": { \"mime_type\": mime_type, \"data\": data } })\n                                    } else {\n                                        network_image_urls.push(url.clone());\n                                        json!({ \"url\": url })\n                                    }\n                                },\n                            })\n                            .collect();\n                        vec![json!({ \"role\": role, \"parts\": parts })]\n                    },\n                    MessageContent::ToolCalls(MessageContentToolCalls { tool_results, .. }) => {\n                        let model_parts: Vec<Value> = tool_results.iter().map(|tool_result| {\n                            json!({\n                                \"functionCall\": {\n                                    \"name\": tool_result.call.name,\n                                    \"args\": tool_result.call.arguments,\n                                }\n                            })\n                        }).collect();\n                        let function_parts: Vec<Value> = tool_results.into_iter().map(|tool_result| {\n                            json!({\n                                \"functionResponse\": {\n                                    \"name\": tool_result.call.name,\n                                    \"response\": {\n                                        \"name\": tool_result.call.name,\n                                        \"content\": tool_result.output,\n                                    }\n                                }\n                            })\n                        }).collect();\n                        vec![\n                            json!({ \"role\": \"model\", \"parts\": model_parts }),\n                            json!({ \"role\": \"function\", \"parts\": function_parts }),\n                        ]\n                    }\n                }\n        })\n        .collect();\n\n    if !network_image_urls.is_empty() {\n        bail!(\n            \"The model does not support network images: {:?}\",\n            network_image_urls\n        );\n    }\n\n    let mut body = json!({ \"contents\": contents, \"generationConfig\": {} });\n\n    if let Some(v) = system_message {\n        body[\"systemInstruction\"] = json!({ \"parts\": [{\"text\": v }] });\n    }\n\n    if let Some(v) = model.max_tokens_param() {\n        body[\"generationConfig\"][\"maxOutputTokens\"] = v.into();\n    }\n    if let Some(v) = temperature {\n        body[\"generationConfig\"][\"temperature\"] = v.into();\n    }\n    if let Some(v) = top_p {\n        body[\"generationConfig\"][\"topP\"] = v.into();\n    }\n\n    if let Some(functions) = functions {\n        // Gemini doesn't support functions with parameters that have empty properties, so we need to patch it.\n        let function_declarations: Vec<_> = functions\n            .into_iter()\n            .map(|function| {\n                if function.parameters.is_empty_properties() {\n                    json!({\n                        \"name\": function.name,\n                        \"description\": function.description,\n                    })\n                } else {\n                    json!(function)\n                }\n            })\n            .collect();\n        body[\"tools\"] = json!([{ \"functionDeclarations\": function_declarations }]);\n    }\n\n    Ok(body)\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum ModelCategory {\n    Gemini,\n    Claude,\n    Mistral,\n}\n\nimpl FromStr for ModelCategory {\n    type Err = anyhow::Error;\n\n    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {\n        if s.starts_with(\"gemini\") {\n            Ok(ModelCategory::Gemini)\n        } else if s.starts_with(\"claude\") {\n            Ok(ModelCategory::Claude)\n        } else if s.starts_with(\"mistral\") || s.starts_with(\"codestral\") {\n            Ok(ModelCategory::Mistral)\n        } else {\n            unsupported_model!(s)\n        }\n    }\n}\n\npub async fn prepare_gcloud_access_token(\n    client: &reqwest::Client,\n    client_name: &str,\n    adc_file: &Option<String>,\n) -> Result<()> {\n    if !is_valid_access_token(client_name) {\n        let (token, expires_in) = fetch_access_token(client, adc_file)\n            .await\n            .with_context(|| \"Failed to fetch access token\")?;\n        let expires_at = Utc::now()\n            + Duration::try_seconds(expires_in)\n                .ok_or_else(|| anyhow!(\"Failed to parse expires_in of access_token\"))?;\n        set_access_token(client_name, token, expires_at.timestamp())\n    }\n    Ok(())\n}\n\nasync fn fetch_access_token(\n    client: &reqwest::Client,\n    file: &Option<String>,\n) -> Result<(String, i64)> {\n    let credentials = load_adc(file).await?;\n    let value: Value = client\n        .post(\"https://oauth2.googleapis.com/token\")\n        .json(&credentials)\n        .send()\n        .await?\n        .json()\n        .await?;\n\n    if let (Some(access_token), Some(expires_in)) =\n        (value[\"access_token\"].as_str(), value[\"expires_in\"].as_i64())\n    {\n        Ok((access_token.to_string(), expires_in))\n    } else if let Some(err_msg) = value[\"error_description\"].as_str() {\n        bail!(\"{err_msg}\")\n    } else {\n        bail!(\"Invalid response data: {value}\")\n    }\n}\n\nasync fn load_adc(file: &Option<String>) -> Result<Value> {\n    let adc_file = file\n        .as_ref()\n        .map(PathBuf::from)\n        .or_else(default_adc_file)\n        .ok_or_else(|| anyhow!(\"No application_default_credentials.json\"))?;\n    let data = tokio::fs::read_to_string(adc_file).await?;\n    let data: Value = serde_json::from_str(&data)?;\n    if let (Some(client_id), Some(client_secret), Some(refresh_token)) = (\n        data[\"client_id\"].as_str(),\n        data[\"client_secret\"].as_str(),\n        data[\"refresh_token\"].as_str(),\n    ) {\n        Ok(json!({\n            \"client_id\": client_id,\n            \"client_secret\": client_secret,\n            \"refresh_token\": refresh_token,\n            \"grant_type\": \"refresh_token\",\n        }))\n    } else {\n        bail!(\"Invalid application_default_credentials.json\")\n    }\n}\n\n#[cfg(not(windows))]\nfn default_adc_file() -> Option<PathBuf> {\n    let mut path = dirs::home_dir()?;\n    path.push(\".config\");\n    path.push(\"gcloud\");\n    path.push(\"application_default_credentials.json\");\n    Some(path)\n}\n\n#[cfg(windows)]\nfn default_adc_file() -> Option<PathBuf> {\n    let mut path = dirs::config_dir()?;\n    path.push(\"gcloud\");\n    path.push(\"application_default_credentials.json\");\n    Some(path)\n}\n\nfn strip_model_version(name: &str) -> &str {\n    match name.split_once('@') {\n        Some((v, _)) => v,\n        None => name,\n    }\n}\n"
  },
  {
    "path": "src/config/agent.rs",
    "content": "use super::*;\n\nuse crate::{\n    client::Model,\n    function::{run_llm_function, Functions},\n};\n\nuse anyhow::{Context, Result};\nuse inquire::{validator::Validation, Text};\nuse std::{fs::read_to_string, path::Path};\n\nuse serde::{Deserialize, Serialize};\n\nconst DEFAULT_AGENT_NAME: &str = \"rag\";\n\npub type AgentVariables = IndexMap<String, String>;\n\n#[derive(Debug, Clone)]\npub struct Agent {\n    name: String,\n    config: AgentConfig,\n    definition: AgentDefinition,\n    shared_variables: AgentVariables,\n    session_variables: Option<AgentVariables>,\n    shared_dynamic_instructions: Option<String>,\n    session_dynamic_instructions: Option<String>,\n    functions: Functions,\n    rag: Option<Arc<Rag>>,\n    model: Model,\n}\n\nimpl Agent {\n    pub async fn init(\n        config: &GlobalConfig,\n        name: &str,\n        abort_signal: AbortSignal,\n    ) -> Result<Self> {\n        let functions_dir = Config::agent_functions_dir(name);\n        let definition_file_path = functions_dir.join(\"index.yaml\");\n        if !definition_file_path.exists() {\n            bail!(\"Unknown agent `{name}`\");\n        }\n        let functions_file_path = functions_dir.join(\"functions.json\");\n        let rag_path = Config::agent_rag_file(name, DEFAULT_AGENT_NAME);\n        let config_path = Config::agent_config_file(name);\n        let mut agent_config = if config_path.exists() {\n            AgentConfig::load(&config_path)?\n        } else {\n            AgentConfig::new(&config.read())\n        };\n        let mut definition = AgentDefinition::load(&definition_file_path)?;\n        let functions = if functions_file_path.exists() {\n            Functions::init(&functions_file_path)?\n        } else {\n            Functions::default()\n        };\n        definition.replace_tools_placeholder(&functions);\n\n        agent_config.load_envs(&definition.name);\n\n        let model = {\n            let config = config.read();\n            match agent_config.model_id.as_ref() {\n                Some(model_id) => Model::retrieve_model(&config, model_id, ModelType::Chat)?,\n                None => {\n                    if agent_config.temperature.is_none() {\n                        agent_config.temperature = config.temperature;\n                    }\n                    if agent_config.top_p.is_none() {\n                        agent_config.top_p = config.top_p;\n                    }\n                    config.current_model().clone()\n                }\n            }\n        };\n\n        let rag = if rag_path.exists() {\n            Some(Arc::new(Rag::load(config, DEFAULT_AGENT_NAME, &rag_path)?))\n        } else if !definition.documents.is_empty() && !config.read().info_flag {\n            let mut ans = false;\n            if *IS_STDOUT_TERMINAL {\n                ans = Confirm::new(\"The agent has the documents, init RAG?\")\n                    .with_default(true)\n                    .prompt()?;\n            }\n            if ans {\n                let mut document_paths = vec![];\n                for path in &definition.documents {\n                    if is_url(path) {\n                        document_paths.push(path.to_string());\n                    } else {\n                        let new_path = safe_join_path(&functions_dir, path)\n                            .ok_or_else(|| anyhow!(\"Invalid document path: '{path}'\"))?;\n                        document_paths.push(new_path.display().to_string())\n                    }\n                }\n                let rag =\n                    Rag::init(config, \"rag\", &rag_path, &document_paths, abort_signal).await?;\n                Some(Arc::new(rag))\n            } else {\n                None\n            }\n        } else {\n            None\n        };\n\n        Ok(Self {\n            name: name.to_string(),\n            config: agent_config,\n            definition,\n            shared_variables: Default::default(),\n            session_variables: None,\n            shared_dynamic_instructions: None,\n            session_dynamic_instructions: None,\n            functions,\n            rag,\n            model,\n        })\n    }\n\n    pub fn init_agent_variables(\n        agent_variables: &[AgentVariable],\n        variables: &AgentVariables,\n        no_interaction: bool,\n    ) -> Result<AgentVariables> {\n        let mut output = IndexMap::new();\n        if agent_variables.is_empty() {\n            return Ok(output);\n        }\n        let mut printed = false;\n        let mut unset_variables = vec![];\n        for agent_variable in agent_variables {\n            let key = agent_variable.name.clone();\n            match variables.get(&key) {\n                Some(value) => {\n                    output.insert(key, value.clone());\n                }\n                None => {\n                    if let Some(value) = agent_variable.default.clone() {\n                        output.insert(key, value);\n                        continue;\n                    }\n                    if no_interaction {\n                        continue;\n                    }\n                    if *IS_STDOUT_TERMINAL {\n                        if !printed {\n                            println!(\"⚙ Init agent variables...\");\n                            printed = true;\n                        }\n                        let value = Text::new(&format!(\n                            \"{} ({}):\",\n                            agent_variable.name, agent_variable.description\n                        ))\n                        .with_validator(|input: &str| {\n                            if input.trim().is_empty() {\n                                Ok(Validation::Invalid(\"This field is required\".into()))\n                            } else {\n                                Ok(Validation::Valid)\n                            }\n                        })\n                        .prompt()?;\n                        output.insert(key, value);\n                    } else {\n                        unset_variables.push(agent_variable)\n                    }\n                }\n            }\n        }\n        if !unset_variables.is_empty() {\n            bail!(\n                \"The following agent variables are required:\\n{}\",\n                unset_variables\n                    .iter()\n                    .map(|v| format!(\"  - {}: {}\", v.name, v.description))\n                    .collect::<Vec<_>>()\n                    .join(\"\\n\")\n            )\n        }\n        Ok(output)\n    }\n\n    pub fn export(&self) -> Result<String> {\n        let mut value = json!({});\n        value[\"name\"] = json!(self.name());\n        let variables = self.variables();\n        if !variables.is_empty() {\n            value[\"variables\"] = serde_json::to_value(variables)?;\n        }\n        value[\"config\"] = json!(self.config);\n        let mut definition = self.definition.clone();\n        definition.instructions = self.interpolated_instructions();\n        value[\"definition\"] = json!(definition);\n        value[\"functions_dir\"] = Config::agent_functions_dir(&self.name)\n            .display()\n            .to_string()\n            .into();\n        value[\"data_dir\"] = Config::agent_data_dir(&self.name)\n            .display()\n            .to_string()\n            .into();\n        value[\"config_file\"] = Config::agent_config_file(&self.name)\n            .display()\n            .to_string()\n            .into();\n        let data = serde_yaml::to_string(&value)?;\n        Ok(data)\n    }\n\n    pub fn banner(&self) -> String {\n        self.definition.banner()\n    }\n\n    pub fn name(&self) -> &str {\n        &self.name\n    }\n\n    pub fn functions(&self) -> &Functions {\n        &self.functions\n    }\n\n    pub fn rag(&self) -> Option<Arc<Rag>> {\n        self.rag.clone()\n    }\n\n    pub fn conversation_staters(&self) -> &[String] {\n        &self.definition.conversation_starters\n    }\n\n    pub fn interpolated_instructions(&self) -> String {\n        let mut output = self\n            .session_dynamic_instructions\n            .clone()\n            .or_else(|| self.shared_dynamic_instructions.clone())\n            .or_else(|| self.config.instructions.clone())\n            .unwrap_or_else(|| self.definition.instructions.clone());\n        for (k, v) in self.variables() {\n            output = output.replace(&format!(\"{{{{{k}}}}}\"), v)\n        }\n        interpolate_variables(&mut output);\n        output\n    }\n\n    pub fn agent_prelude(&self) -> Option<&str> {\n        self.config.agent_prelude.as_deref()\n    }\n\n    pub fn variables(&self) -> &AgentVariables {\n        match &self.session_variables {\n            Some(variables) => variables,\n            None => &self.shared_variables,\n        }\n    }\n\n    pub fn variable_envs(&self) -> HashMap<String, String> {\n        self.variables()\n            .iter()\n            .map(|(k, v)| {\n                (\n                    format!(\"LLM_AGENT_VAR_{}\", normalize_env_name(k)),\n                    v.clone(),\n                )\n            })\n            .collect()\n    }\n\n    pub fn config_variables(&self) -> &AgentVariables {\n        &self.config.variables\n    }\n\n    pub fn shared_variables(&self) -> &AgentVariables {\n        &self.shared_variables\n    }\n\n    pub fn set_shared_variables(&mut self, shared_variables: AgentVariables) {\n        self.shared_variables = shared_variables;\n    }\n\n    pub fn set_session_variables(&mut self, session_variables: AgentVariables) {\n        self.session_variables = Some(session_variables);\n    }\n\n    pub fn defined_variables(&self) -> &[AgentVariable] {\n        &self.definition.variables\n    }\n\n    pub fn exit_session(&mut self) {\n        self.session_variables = None;\n        self.session_dynamic_instructions = None;\n    }\n\n    pub fn is_dynamic_instructions(&self) -> bool {\n        self.definition.dynamic_instructions\n    }\n\n    pub fn update_shared_dynamic_instructions(&mut self, force: bool) -> Result<()> {\n        if self.is_dynamic_instructions() && (force || self.shared_dynamic_instructions.is_none()) {\n            self.shared_dynamic_instructions = Some(self.run_instructions_fn()?);\n        }\n        Ok(())\n    }\n\n    pub fn update_session_dynamic_instructions(&mut self, value: Option<String>) -> Result<()> {\n        if self.is_dynamic_instructions() {\n            let value = match value {\n                Some(v) => v,\n                None => self.run_instructions_fn()?,\n            };\n            self.session_dynamic_instructions = Some(value);\n        }\n        Ok(())\n    }\n\n    fn run_instructions_fn(&self) -> Result<String> {\n        let value = run_llm_function(\n            self.name().to_string(),\n            vec![\"_instructions\".into(), \"{}\".into()],\n            self.variable_envs(),\n        )?;\n        match value {\n            Some(v) => Ok(v),\n            _ => bail!(\"No return value from '_instructions' function\"),\n        }\n    }\n}\n\nimpl RoleLike for Agent {\n    fn to_role(&self) -> Role {\n        let prompt = self.interpolated_instructions();\n        let mut role = Role::new(\"\", &prompt);\n        role.sync(self);\n        role\n    }\n\n    fn model(&self) -> &Model {\n        &self.model\n    }\n\n    fn temperature(&self) -> Option<f64> {\n        self.config.temperature\n    }\n\n    fn top_p(&self) -> Option<f64> {\n        self.config.top_p\n    }\n\n    fn use_tools(&self) -> Option<String> {\n        self.config.use_tools.clone()\n    }\n\n    fn set_model(&mut self, model: Model) {\n        self.config.model_id = Some(model.id());\n        self.model = model;\n    }\n\n    fn set_temperature(&mut self, value: Option<f64>) {\n        self.config.temperature = value;\n    }\n\n    fn set_top_p(&mut self, value: Option<f64>) {\n        self.config.top_p = value;\n    }\n\n    fn set_use_tools(&mut self, value: Option<String>) {\n        self.config.use_tools = value;\n    }\n}\n\n#[derive(Debug, Clone, Default, Deserialize, Serialize)]\npub struct AgentConfig {\n    #[serde(rename(serialize = \"model\", deserialize = \"model\"))]\n    pub model_id: Option<String>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub temperature: Option<f64>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub top_p: Option<f64>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub use_tools: Option<String>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub agent_prelude: Option<String>,\n    #[serde(default, skip_serializing_if = \"Option::is_none\")]\n    pub instructions: Option<String>,\n    #[serde(default, skip_serializing_if = \"IndexMap::is_empty\")]\n    pub variables: AgentVariables,\n}\n\nimpl AgentConfig {\n    pub fn new(config: &Config) -> Self {\n        Self {\n            use_tools: config.use_tools.clone(),\n            agent_prelude: config.agent_prelude.clone(),\n            ..Default::default()\n        }\n    }\n\n    pub fn load(path: &Path) -> Result<Self> {\n        let contents = read_to_string(path)\n            .with_context(|| format!(\"Failed to read agent config file at '{}'\", path.display()))?;\n        let config: Self = serde_yaml::from_str(&contents)\n            .with_context(|| format!(\"Failed to load agent config at '{}'\", path.display()))?;\n        Ok(config)\n    }\n\n    fn load_envs(&mut self, name: &str) {\n        let with_prefix = |v: &str| normalize_env_name(&format!(\"{name}_{v}\"));\n\n        if let Some(v) = read_env_value::<String>(&with_prefix(\"model\")) {\n            self.model_id = v;\n        }\n        if let Some(v) = read_env_value::<f64>(&with_prefix(\"temperature\")) {\n            self.temperature = v;\n        }\n        if let Some(v) = read_env_value::<f64>(&with_prefix(\"top_p\")) {\n            self.top_p = v;\n        }\n        if let Some(v) = read_env_value::<String>(&with_prefix(\"use_tools\")) {\n            self.use_tools = v;\n        }\n        if let Some(v) = read_env_value::<String>(&with_prefix(\"agent_prelude\")) {\n            self.agent_prelude = v;\n        }\n        if let Some(v) = read_env_value::<String>(&with_prefix(\"instructions\")) {\n            self.instructions = v;\n        }\n        if let Ok(v) = env::var(with_prefix(\"variables\")) {\n            if let Ok(v) = serde_json::from_str(&v) {\n                self.variables = v;\n            }\n        }\n    }\n}\n\n#[derive(Debug, Clone, Default, Deserialize, Serialize)]\npub struct AgentDefinition {\n    pub name: String,\n    #[serde(default)]\n    pub description: String,\n    #[serde(default)]\n    pub version: String,\n    #[serde(default)]\n    pub instructions: String,\n    #[serde(default)]\n    pub dynamic_instructions: bool,\n    #[serde(default)]\n    pub variables: Vec<AgentVariable>,\n    #[serde(default)]\n    pub conversation_starters: Vec<String>,\n    #[serde(default)]\n    pub documents: Vec<String>,\n}\n\nimpl AgentDefinition {\n    pub fn load(path: &Path) -> Result<Self> {\n        let contents = read_to_string(path)\n            .with_context(|| format!(\"Failed to read agent index file at '{}'\", path.display()))?;\n        let definition: Self = serde_yaml::from_str(&contents)\n            .with_context(|| format!(\"Failed to load agent index at '{}'\", path.display()))?;\n        Ok(definition)\n    }\n\n    fn banner(&self) -> String {\n        let AgentDefinition {\n            name,\n            description,\n            version,\n            conversation_starters,\n            ..\n        } = self;\n        let starters = if conversation_starters.is_empty() {\n            String::new()\n        } else {\n            let starters = conversation_starters\n                .iter()\n                .map(|v| format!(\"- {v}\"))\n                .collect::<Vec<_>>()\n                .join(\"\\n\");\n            format!(\n                r#\"\n\n## Conversation Starters\n{starters}\"#\n            )\n        };\n        format!(\n            r#\"# {name} {version}\n{description}{starters}\"#\n        )\n    }\n\n    fn replace_tools_placeholder(&mut self, functions: &Functions) {\n        let tools_placeholder: &str = \"{{__tools__}}\";\n        if self.instructions.contains(tools_placeholder) {\n            let tools = functions\n                .declarations()\n                .iter()\n                .enumerate()\n                .map(|(i, v)| {\n                    let description = match v.description.split_once('\\n') {\n                        Some((v, _)) => v,\n                        None => &v.description,\n                    };\n                    format!(\"{}. {}: {description}\", i + 1, v.name)\n                })\n                .collect::<Vec<String>>()\n                .join(\"\\n\");\n            self.instructions = self.instructions.replace(tools_placeholder, &tools);\n        }\n    }\n}\n\n#[derive(Debug, Clone, Default, Deserialize, Serialize)]\npub struct AgentVariable {\n    pub name: String,\n    pub description: String,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub default: Option<String>,\n    #[serde(skip_deserializing, default)]\n    pub value: String,\n}\n\npub fn list_agents() -> Vec<String> {\n    let agents_file = Config::functions_dir().join(\"agents.txt\");\n    let contents = match read_to_string(agents_file) {\n        Ok(v) => v,\n        Err(_) => return vec![],\n    };\n    contents\n        .split('\\n')\n        .filter_map(|line| {\n            let line = line.trim();\n            if line.is_empty() || line.starts_with('#') {\n                None\n            } else {\n                Some(line.to_string())\n            }\n        })\n        .collect()\n}\n\npub fn complete_agent_variables(agent_name: &str) -> Vec<(String, Option<String>)> {\n    let index_path = Config::agent_functions_dir(agent_name).join(\"index.yaml\");\n    if !index_path.exists() {\n        return vec![];\n    }\n    let Ok(definition) = AgentDefinition::load(&index_path) else {\n        return vec![];\n    };\n    definition\n        .variables\n        .iter()\n        .map(|v| {\n            let description = match &v.default {\n                Some(default) => format!(\"{} [default: {default}]\", v.description),\n                None => v.description.clone(),\n            };\n            (format!(\"{}=\", v.name), Some(description))\n        })\n        .collect()\n}\n"
  },
  {
    "path": "src/config/input.rs",
    "content": "use super::*;\n\nuse crate::client::{\n    init_client, patch_messages, ChatCompletionsData, Client, ImageUrl, Message, MessageContent,\n    MessageContentPart, MessageContentToolCalls, MessageRole, Model,\n};\nuse crate::function::ToolResult;\nuse crate::utils::{base64_encode, is_loader_protocol, sha256, AbortSignal};\n\nuse anyhow::{bail, Context, Result};\nuse indexmap::IndexSet;\nuse std::{collections::HashMap, fs::File, io::Read};\nuse unicode_width::{UnicodeWidthChar, UnicodeWidthStr};\n\nconst IMAGE_EXTS: [&str; 5] = [\"png\", \"jpeg\", \"jpg\", \"webp\", \"gif\"];\nconst SUMMARY_MAX_WIDTH: usize = 80;\n\n#[derive(Debug, Clone)]\npub struct Input {\n    config: GlobalConfig,\n    text: String,\n    raw: (String, Vec<String>),\n    patched_text: Option<String>,\n    last_reply: Option<String>,\n    continue_output: Option<String>,\n    regenerate: bool,\n    medias: Vec<String>,\n    data_urls: HashMap<String, String>,\n    tool_calls: Option<MessageContentToolCalls>,\n    role: Role,\n    rag_name: Option<String>,\n    with_session: bool,\n    with_agent: bool,\n}\n\nimpl Input {\n    pub fn from_str(config: &GlobalConfig, text: &str, role: Option<Role>) -> Self {\n        let (role, with_session, with_agent) = resolve_role(&config.read(), role);\n        Self {\n            config: config.clone(),\n            text: text.to_string(),\n            raw: (text.to_string(), vec![]),\n            patched_text: None,\n            last_reply: None,\n            continue_output: None,\n            regenerate: false,\n            medias: Default::default(),\n            data_urls: Default::default(),\n            tool_calls: None,\n            role,\n            rag_name: None,\n            with_session,\n            with_agent,\n        }\n    }\n\n    pub async fn from_files(\n        config: &GlobalConfig,\n        raw_text: &str,\n        paths: Vec<String>,\n        role: Option<Role>,\n    ) -> Result<Self> {\n        let loaders = config.read().document_loaders.clone();\n        let (raw_paths, local_paths, remote_urls, external_cmds, protocol_paths, with_last_reply) =\n            resolve_paths(&loaders, paths)?;\n        let mut last_reply = None;\n        let (documents, medias, data_urls) = load_documents(\n            &loaders,\n            local_paths,\n            remote_urls,\n            external_cmds,\n            protocol_paths,\n        )\n        .await\n        .context(\"Failed to load files\")?;\n        let mut texts = vec![];\n        if !raw_text.is_empty() {\n            texts.push(raw_text.to_string());\n        };\n        if with_last_reply {\n            if let Some(LastMessage { input, output, .. }) = config.read().last_message.as_ref() {\n                if !output.is_empty() {\n                    last_reply = Some(output.clone())\n                } else if let Some(v) = input.last_reply.as_ref() {\n                    last_reply = Some(v.clone());\n                }\n                if let Some(v) = last_reply.clone() {\n                    texts.push(format!(\"\\n{v}\"));\n                }\n            }\n            if last_reply.is_none() && documents.is_empty() && medias.is_empty() {\n                bail!(\"No last reply found\");\n            }\n        }\n        let documents_len = documents.len();\n        for (kind, path, contents) in documents {\n            if documents_len == 1 && raw_text.is_empty() {\n                texts.push(format!(\"\\n{contents}\"));\n            } else {\n                texts.push(format!(\n                    \"\\n============ {kind}: {path} ============\\n{contents}\"\n                ));\n            }\n        }\n        let (role, with_session, with_agent) = resolve_role(&config.read(), role);\n        Ok(Self {\n            config: config.clone(),\n            text: texts.join(\"\\n\"),\n            raw: (raw_text.to_string(), raw_paths),\n            patched_text: None,\n            last_reply,\n            continue_output: None,\n            regenerate: false,\n            medias,\n            data_urls,\n            tool_calls: Default::default(),\n            role,\n            rag_name: None,\n            with_session,\n            with_agent,\n        })\n    }\n\n    pub async fn from_files_with_spinner(\n        config: &GlobalConfig,\n        raw_text: &str,\n        paths: Vec<String>,\n        role: Option<Role>,\n        abort_signal: AbortSignal,\n    ) -> Result<Self> {\n        abortable_run_with_spinner(\n            Input::from_files(config, raw_text, paths, role),\n            \"Loading files\",\n            abort_signal,\n        )\n        .await\n    }\n\n    pub fn is_empty(&self) -> bool {\n        self.text.is_empty() && self.medias.is_empty()\n    }\n\n    pub fn data_urls(&self) -> HashMap<String, String> {\n        self.data_urls.clone()\n    }\n\n    pub fn tool_calls(&self) -> &Option<MessageContentToolCalls> {\n        &self.tool_calls\n    }\n\n    pub fn text(&self) -> String {\n        match self.patched_text.clone() {\n            Some(text) => text,\n            None => self.text.clone(),\n        }\n    }\n\n    pub fn clear_patch(&mut self) {\n        self.patched_text = None;\n    }\n\n    pub fn set_text(&mut self, text: String) {\n        self.text = text;\n    }\n\n    pub fn stream(&self) -> bool {\n        self.config.read().stream && !self.role().model().no_stream()\n    }\n\n    pub fn continue_output(&self) -> Option<&str> {\n        self.continue_output.as_deref()\n    }\n\n    pub fn set_continue_output(&mut self, output: &str) {\n        let output = match &self.continue_output {\n            Some(v) => format!(\"{v}{output}\"),\n            None => output.to_string(),\n        };\n        self.continue_output = Some(output);\n    }\n\n    pub fn regenerate(&self) -> bool {\n        self.regenerate\n    }\n\n    pub fn set_regenerate(&mut self) {\n        let role = self.config.read().extract_role();\n        if role.name() == self.role().name() {\n            self.role = role;\n        }\n        self.regenerate = true;\n        self.tool_calls = None;\n    }\n\n    pub async fn use_embeddings(&mut self, abort_signal: AbortSignal) -> Result<()> {\n        if self.text.is_empty() {\n            return Ok(());\n        }\n        let rag = self.config.read().rag.clone();\n        if let Some(rag) = rag {\n            let result = Config::search_rag(&self.config, &rag, &self.text, abort_signal).await?;\n            self.patched_text = Some(result);\n            self.rag_name = Some(rag.name().to_string());\n        }\n        Ok(())\n    }\n\n    pub fn rag_name(&self) -> Option<&str> {\n        self.rag_name.as_deref()\n    }\n\n    pub fn merge_tool_results(mut self, output: String, tool_results: Vec<ToolResult>) -> Self {\n        match self.tool_calls.as_mut() {\n            Some(exist_tool_results) => {\n                exist_tool_results.merge(tool_results, output);\n            }\n            None => self.tool_calls = Some(MessageContentToolCalls::new(tool_results, output)),\n        }\n        self\n    }\n\n    pub fn create_client(&self) -> Result<Box<dyn Client>> {\n        init_client(&self.config, Some(self.role().model().clone()))\n    }\n\n    pub async fn fetch_chat_text(&self) -> Result<String> {\n        let client = self.create_client()?;\n        let text = client.chat_completions(self.clone()).await?.text;\n        let text = strip_think_tag(&text).to_string();\n        Ok(text)\n    }\n\n    pub fn prepare_completion_data(\n        &self,\n        model: &Model,\n        stream: bool,\n    ) -> Result<ChatCompletionsData> {\n        let mut messages = self.build_messages()?;\n        patch_messages(&mut messages, model);\n        model.guard_max_input_tokens(&messages)?;\n        let (temperature, top_p) = (self.role().temperature(), self.role().top_p());\n        let functions = self.config.read().select_functions(self.role());\n        Ok(ChatCompletionsData {\n            messages,\n            temperature,\n            top_p,\n            functions,\n            stream,\n        })\n    }\n\n    pub fn build_messages(&self) -> Result<Vec<Message>> {\n        let mut messages = if let Some(session) = self.session(&self.config.read().session) {\n            session.build_messages(self)\n        } else {\n            self.role().build_messages(self)\n        };\n        if let Some(tool_calls) = &self.tool_calls {\n            messages.push(Message::new(\n                MessageRole::Assistant,\n                MessageContent::ToolCalls(tool_calls.clone()),\n            ))\n        }\n        Ok(messages)\n    }\n\n    pub fn echo_messages(&self) -> String {\n        if let Some(session) = self.session(&self.config.read().session) {\n            session.echo_messages(self)\n        } else {\n            self.role().echo_messages(self)\n        }\n    }\n\n    pub fn role(&self) -> &Role {\n        &self.role\n    }\n\n    pub fn session<'a>(&self, session: &'a Option<Session>) -> Option<&'a Session> {\n        if self.with_session {\n            session.as_ref()\n        } else {\n            None\n        }\n    }\n\n    pub fn session_mut<'a>(&self, session: &'a mut Option<Session>) -> Option<&'a mut Session> {\n        if self.with_session {\n            session.as_mut()\n        } else {\n            None\n        }\n    }\n\n    pub fn with_agent(&self) -> bool {\n        self.with_agent\n    }\n\n    pub fn summary(&self) -> String {\n        let text: String = self\n            .text\n            .trim()\n            .chars()\n            .map(|c| if c.is_control() { ' ' } else { c })\n            .collect();\n        if text.width_cjk() > SUMMARY_MAX_WIDTH {\n            let mut sum_width = 0;\n            let mut chars = vec![];\n            for c in text.chars() {\n                sum_width += c.width_cjk().unwrap_or(1);\n                if sum_width > SUMMARY_MAX_WIDTH - 3 {\n                    chars.extend(['.', '.', '.']);\n                    break;\n                }\n                chars.push(c);\n            }\n            chars.into_iter().collect()\n        } else {\n            text\n        }\n    }\n\n    pub fn raw(&self) -> String {\n        let (text, files) = &self.raw;\n        let mut segments = files.to_vec();\n        if !segments.is_empty() {\n            segments.insert(0, \".file\".into());\n        }\n        if !text.is_empty() {\n            if !segments.is_empty() {\n                segments.push(\"--\".into());\n            }\n            segments.push(text.clone());\n        }\n        segments.join(\" \")\n    }\n\n    pub fn render(&self) -> String {\n        let text = self.text();\n        if self.medias.is_empty() {\n            return text;\n        }\n        let tail_text = if text.is_empty() {\n            String::new()\n        } else {\n            format!(\" -- {text}\")\n        };\n        let files: Vec<String> = self\n            .medias\n            .iter()\n            .cloned()\n            .map(|url| resolve_data_url(&self.data_urls, url))\n            .collect();\n        format!(\".file {}{}\", files.join(\" \"), tail_text)\n    }\n\n    pub fn message_content(&self) -> MessageContent {\n        if self.medias.is_empty() {\n            MessageContent::Text(self.text())\n        } else {\n            let mut list: Vec<MessageContentPart> = self\n                .medias\n                .iter()\n                .cloned()\n                .map(|url| MessageContentPart::ImageUrl {\n                    image_url: ImageUrl { url },\n                })\n                .collect();\n            if !self.text.is_empty() {\n                list.insert(0, MessageContentPart::Text { text: self.text() });\n            }\n            MessageContent::Array(list)\n        }\n    }\n}\n\nfn resolve_role(config: &Config, role: Option<Role>) -> (Role, bool, bool) {\n    match role {\n        Some(v) => (v, false, false),\n        None => (\n            config.extract_role(),\n            config.session.is_some(),\n            config.agent.is_some(),\n        ),\n    }\n}\n\ntype ResolvePathsOutput = (\n    Vec<String>,\n    Vec<String>,\n    Vec<String>,\n    Vec<String>,\n    Vec<String>,\n    bool,\n);\n\nfn resolve_paths(\n    loaders: &HashMap<String, String>,\n    paths: Vec<String>,\n) -> Result<ResolvePathsOutput> {\n    let mut raw_paths = IndexSet::new();\n    let mut local_paths = IndexSet::new();\n    let mut remote_urls = IndexSet::new();\n    let mut external_cmds = IndexSet::new();\n    let mut protocol_paths = IndexSet::new();\n    let mut with_last_reply = false;\n    for path in paths {\n        if path == \"%%\" {\n            with_last_reply = true;\n            raw_paths.insert(path);\n        } else if path.starts_with('`') && path.len() > 2 && path.ends_with('`') {\n            external_cmds.insert(path[1..path.len() - 1].to_string());\n            raw_paths.insert(path);\n        } else if is_url(&path) {\n            if path.strip_suffix(\"**\").is_some() {\n                bail!(\"Invalid website '{path}'\");\n            }\n            remote_urls.insert(path.clone());\n            raw_paths.insert(path);\n        } else if is_loader_protocol(loaders, &path) {\n            protocol_paths.insert(path.clone());\n            raw_paths.insert(path);\n        } else {\n            let resolved_path = resolve_home_dir(&path);\n            let absolute_path = to_absolute_path(&resolved_path)\n                .with_context(|| format!(\"Invalid path '{path}'\"))?;\n            local_paths.insert(resolved_path);\n            raw_paths.insert(absolute_path);\n        }\n    }\n    Ok((\n        raw_paths.into_iter().collect(),\n        local_paths.into_iter().collect(),\n        remote_urls.into_iter().collect(),\n        external_cmds.into_iter().collect(),\n        protocol_paths.into_iter().collect(),\n        with_last_reply,\n    ))\n}\n\nasync fn load_documents(\n    loaders: &HashMap<String, String>,\n    local_paths: Vec<String>,\n    remote_urls: Vec<String>,\n    external_cmds: Vec<String>,\n    protocol_paths: Vec<String>,\n) -> Result<(\n    Vec<(&'static str, String, String)>,\n    Vec<String>,\n    HashMap<String, String>,\n)> {\n    let mut files = vec![];\n    let mut medias = vec![];\n    let mut data_urls = HashMap::new();\n\n    for cmd in external_cmds {\n        let output = duct::cmd(&SHELL.cmd, &[&SHELL.arg, &cmd])\n            .stderr_to_stdout()\n            .unchecked()\n            .read()\n            .unwrap_or_else(|err| err.to_string());\n        files.push((\"CMD\", cmd, output));\n    }\n\n    let local_files = expand_glob_paths(&local_paths, true).await?;\n    for file_path in local_files {\n        if is_image(&file_path) {\n            let contents = read_media_to_data_url(&file_path)\n                .with_context(|| format!(\"Unable to read media '{file_path}'\"))?;\n            data_urls.insert(sha256(&contents), file_path);\n            medias.push(contents)\n        } else {\n            let document = load_file(loaders, &file_path)\n                .await\n                .with_context(|| format!(\"Unable to read file '{file_path}'\"))?;\n            files.push((\"FILE\", file_path, document.contents));\n        }\n    }\n\n    for file_url in remote_urls {\n        let (contents, extension) = fetch_with_loaders(loaders, &file_url, true)\n            .await\n            .with_context(|| format!(\"Failed to load url '{file_url}'\"))?;\n        if extension == MEDIA_URL_EXTENSION {\n            data_urls.insert(sha256(&contents), file_url);\n            medias.push(contents)\n        } else {\n            files.push((\"URL\", file_url, contents));\n        }\n    }\n\n    for protocol_path in protocol_paths {\n        let documents = load_protocol_path(loaders, &protocol_path)\n            .with_context(|| format!(\"Failed to load from '{protocol_path}'\"))?;\n        files.extend(\n            documents\n                .into_iter()\n                .map(|document| (\"FROM\", document.path, document.contents)),\n        );\n    }\n\n    Ok((files, medias, data_urls))\n}\n\npub fn resolve_data_url(data_urls: &HashMap<String, String>, data_url: String) -> String {\n    if data_url.starts_with(\"data:\") {\n        let hash = sha256(&data_url);\n        if let Some(path) = data_urls.get(&hash) {\n            return path.to_string();\n        }\n        data_url\n    } else {\n        data_url\n    }\n}\n\nfn is_image(path: &str) -> bool {\n    get_patch_extension(path)\n        .map(|v| IMAGE_EXTS.contains(&v.as_str()))\n        .unwrap_or_default()\n}\n\nfn read_media_to_data_url(image_path: &str) -> Result<String> {\n    let extension = get_patch_extension(image_path).unwrap_or_default();\n    let mime_type = match extension.as_str() {\n        \"png\" => \"image/png\",\n        \"jpg\" | \"jpeg\" => \"image/jpeg\",\n        \"webp\" => \"image/webp\",\n        \"gif\" => \"image/gif\",\n        _ => bail!(\"Unexpected media type\"),\n    };\n    let mut file = File::open(image_path)?;\n    let mut buffer = Vec::new();\n    file.read_to_end(&mut buffer)?;\n\n    let encoded_image = base64_encode(buffer);\n    let data_url = format!(\"data:{mime_type};base64,{encoded_image}\");\n\n    Ok(data_url)\n}\n"
  },
  {
    "path": "src/config/mod.rs",
    "content": "mod agent;\nmod input;\nmod role;\nmod session;\n\npub use self::agent::{complete_agent_variables, list_agents, Agent, AgentVariables};\npub use self::input::Input;\npub use self::role::{\n    Role, RoleLike, CODE_ROLE, CREATE_TITLE_ROLE, EXPLAIN_SHELL_ROLE, SHELL_ROLE,\n};\nuse self::session::Session;\n\nuse crate::client::{\n    create_client_config, list_client_types, list_models, ClientConfig, MessageContentToolCalls,\n    Model, ModelType, ProviderModels, OPENAI_COMPATIBLE_PROVIDERS,\n};\nuse crate::function::{FunctionDeclaration, Functions, ToolResult};\nuse crate::rag::Rag;\nuse crate::render::{MarkdownRender, RenderOptions};\nuse crate::repl::{run_repl_command, split_args_text};\nuse crate::utils::*;\n\nuse anyhow::{anyhow, bail, Context, Result};\nuse indexmap::IndexMap;\nuse inquire::{list_option::ListOption, validator::Validation, Confirm, MultiSelect, Select, Text};\nuse parking_lot::RwLock;\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\nuse simplelog::LevelFilter;\nuse std::collections::{HashMap, HashSet};\nuse std::{\n    env,\n    fs::{\n        create_dir_all, read_dir, read_to_string, remove_dir_all, remove_file, File, OpenOptions,\n    },\n    io::Write,\n    path::{Path, PathBuf},\n    process,\n    sync::{Arc, OnceLock},\n};\nuse syntect::highlighting::ThemeSet;\nuse terminal_colorsaurus::{color_scheme, ColorScheme, QueryOptions};\n\npub const TEMP_ROLE_NAME: &str = \"%%\";\npub const TEMP_RAG_NAME: &str = \"temp\";\npub const TEMP_SESSION_NAME: &str = \"temp\";\n\n/// Monokai Extended\nconst DARK_THEME: &[u8] = include_bytes!(\"../../assets/monokai-extended.theme.bin\");\nconst LIGHT_THEME: &[u8] = include_bytes!(\"../../assets/monokai-extended-light.theme.bin\");\n\nconst CONFIG_FILE_NAME: &str = \"config.yaml\";\nconst ROLES_DIR_NAME: &str = \"roles\";\nconst MACROS_DIR_NAME: &str = \"macros\";\nconst ENV_FILE_NAME: &str = \".env\";\nconst MESSAGES_FILE_NAME: &str = \"messages.md\";\nconst SESSIONS_DIR_NAME: &str = \"sessions\";\nconst RAGS_DIR_NAME: &str = \"rags\";\nconst FUNCTIONS_DIR_NAME: &str = \"functions\";\nconst FUNCTIONS_FILE_NAME: &str = \"functions.json\";\nconst FUNCTIONS_BIN_DIR_NAME: &str = \"bin\";\nconst AGENTS_DIR_NAME: &str = \"agents\";\n\nconst CLIENTS_FIELD: &str = \"clients\";\n\nconst SERVE_ADDR: &str = \"127.0.0.1:8000\";\n\nconst SYNC_MODELS_URL: &str =\n    \"https://raw.githubusercontent.com/sigoden/aichat/refs/heads/main/models.yaml\";\n\nconst SUMMARIZE_PROMPT: &str =\n    \"Summarize the discussion briefly in 200 words or less to use as a prompt for future context.\";\nconst SUMMARY_PROMPT: &str = \"This is a summary of the chat history as a recap: \";\n\nconst RAG_TEMPLATE: &str = r#\"Answer the query based on the context while respecting the rules. (user query, some textual context and rules, all inside xml tags)\n\n<context>\n__CONTEXT__\n</context>\n\n<rules>\n- If you don't know, just say so.\n- If you are not sure, ask for clarification.\n- Answer in the same language as the user query.\n- If the context appears unreadable or of poor quality, tell the user then answer as best as you can.\n- If the answer is not in the context but you think you know the answer, explain that to the user then answer with your own knowledge.\n- Answer directly and without using xml tags.\n</rules>\n\n<user_query>\n__INPUT__\n</user_query>\"#;\n\nconst LEFT_PROMPT: &str = \"{color.green}{?session {?agent {agent}>}{session}{?role /}}{!session {?agent {agent}>}}{role}{?rag @{rag}}{color.cyan}{?session )}{!session >}{color.reset} \";\nconst RIGHT_PROMPT: &str = \"{color.purple}{?session {?consume_tokens {consume_tokens}({consume_percent}%)}{!consume_tokens {consume_tokens}}}{color.reset}\";\n\nstatic EDITOR: OnceLock<Option<String>> = OnceLock::new();\n\n#[derive(Debug, Clone, Deserialize)]\n#[serde(default)]\npub struct Config {\n    #[serde(rename(serialize = \"model\", deserialize = \"model\"))]\n    #[serde(default)]\n    pub model_id: String,\n    pub temperature: Option<f64>,\n    pub top_p: Option<f64>,\n\n    pub dry_run: bool,\n    pub stream: bool,\n    pub save: bool,\n    pub keybindings: String,\n    pub editor: Option<String>,\n    pub wrap: Option<String>,\n    pub wrap_code: bool,\n\n    pub function_calling: bool,\n    pub mapping_tools: IndexMap<String, String>,\n    pub use_tools: Option<String>,\n\n    pub repl_prelude: Option<String>,\n    pub cmd_prelude: Option<String>,\n    pub agent_prelude: Option<String>,\n\n    pub save_session: Option<bool>,\n    pub compress_threshold: usize,\n    pub summarize_prompt: Option<String>,\n    pub summary_prompt: Option<String>,\n\n    pub rag_embedding_model: Option<String>,\n    pub rag_reranker_model: Option<String>,\n    pub rag_top_k: usize,\n    pub rag_chunk_size: Option<usize>,\n    pub rag_chunk_overlap: Option<usize>,\n    pub rag_template: Option<String>,\n\n    #[serde(default)]\n    pub document_loaders: HashMap<String, String>,\n\n    pub highlight: bool,\n    pub theme: Option<String>,\n    pub left_prompt: Option<String>,\n    pub right_prompt: Option<String>,\n\n    pub serve_addr: Option<String>,\n    pub user_agent: Option<String>,\n    pub save_shell_history: bool,\n    pub sync_models_url: Option<String>,\n\n    pub clients: Vec<ClientConfig>,\n\n    #[serde(skip)]\n    pub macro_flag: bool,\n    #[serde(skip)]\n    pub info_flag: bool,\n    #[serde(skip)]\n    pub agent_variables: Option<AgentVariables>,\n\n    #[serde(skip)]\n    pub model: Model,\n    #[serde(skip)]\n    pub functions: Functions,\n    #[serde(skip)]\n    pub working_mode: WorkingMode,\n    #[serde(skip)]\n    pub last_message: Option<LastMessage>,\n\n    #[serde(skip)]\n    pub role: Option<Role>,\n    #[serde(skip)]\n    pub session: Option<Session>,\n    #[serde(skip)]\n    pub rag: Option<Arc<Rag>>,\n    #[serde(skip)]\n    pub agent: Option<Agent>,\n}\n\nimpl Default for Config {\n    fn default() -> Self {\n        Self {\n            model_id: Default::default(),\n            temperature: None,\n            top_p: None,\n\n            dry_run: false,\n            stream: true,\n            save: false,\n            keybindings: \"emacs\".into(),\n            editor: None,\n            wrap: None,\n            wrap_code: false,\n\n            function_calling: true,\n            mapping_tools: Default::default(),\n            use_tools: None,\n\n            repl_prelude: None,\n            cmd_prelude: None,\n            agent_prelude: None,\n\n            save_session: None,\n            compress_threshold: 4000,\n            summarize_prompt: None,\n            summary_prompt: None,\n\n            rag_embedding_model: None,\n            rag_reranker_model: None,\n            rag_top_k: 5,\n            rag_chunk_size: None,\n            rag_chunk_overlap: None,\n            rag_template: None,\n\n            document_loaders: Default::default(),\n\n            highlight: true,\n            theme: None,\n            left_prompt: None,\n            right_prompt: None,\n\n            serve_addr: None,\n            user_agent: None,\n            save_shell_history: true,\n            sync_models_url: None,\n\n            clients: vec![],\n\n            macro_flag: false,\n            info_flag: false,\n            agent_variables: None,\n\n            model: Default::default(),\n            functions: Default::default(),\n            working_mode: WorkingMode::Cmd,\n            last_message: None,\n\n            role: None,\n            session: None,\n            rag: None,\n            agent: None,\n        }\n    }\n}\n\npub type GlobalConfig = Arc<RwLock<Config>>;\n\nimpl Config {\n    pub async fn init(working_mode: WorkingMode, info_flag: bool) -> Result<Self> {\n        let config_path = Self::config_file();\n        let mut config = if !config_path.exists() {\n            match env::var(get_env_name(\"provider\"))\n                .ok()\n                .or_else(|| env::var(get_env_name(\"platform\")).ok())\n            {\n                Some(v) => Self::load_dynamic(&v)?,\n                None => {\n                    if *IS_STDOUT_TERMINAL {\n                        create_config_file(&config_path).await?;\n                    }\n                    Self::load_from_file(&config_path)?\n                }\n            }\n        } else {\n            Self::load_from_file(&config_path)?\n        };\n\n        config.working_mode = working_mode;\n        config.info_flag = info_flag;\n\n        let setup = |config: &mut Self| -> Result<()> {\n            config.load_envs();\n\n            if let Some(wrap) = config.wrap.clone() {\n                config.set_wrap(&wrap)?;\n            }\n\n            config.load_functions()?;\n\n            config.setup_model()?;\n            config.setup_document_loaders();\n            config.setup_user_agent();\n            Ok(())\n        };\n        let ret = setup(&mut config);\n        if !info_flag {\n            ret?;\n        }\n        Ok(config)\n    }\n\n    pub fn config_dir() -> PathBuf {\n        if let Ok(v) = env::var(get_env_name(\"config_dir\")) {\n            PathBuf::from(v)\n        } else if let Ok(v) = env::var(\"XDG_CONFIG_HOME\") {\n            PathBuf::from(v).join(env!(\"CARGO_CRATE_NAME\"))\n        } else {\n            let dir = dirs::config_dir().expect(\"No user's config directory\");\n            dir.join(env!(\"CARGO_CRATE_NAME\"))\n        }\n    }\n\n    pub fn local_path(name: &str) -> PathBuf {\n        Self::config_dir().join(name)\n    }\n\n    pub fn config_file() -> PathBuf {\n        match env::var(get_env_name(\"config_file\")) {\n            Ok(value) => PathBuf::from(value),\n            Err(_) => Self::local_path(CONFIG_FILE_NAME),\n        }\n    }\n\n    pub fn roles_dir() -> PathBuf {\n        match env::var(get_env_name(\"roles_dir\")) {\n            Ok(value) => PathBuf::from(value),\n            Err(_) => Self::local_path(ROLES_DIR_NAME),\n        }\n    }\n\n    pub fn role_file(name: &str) -> PathBuf {\n        Self::roles_dir().join(format!(\"{name}.md\"))\n    }\n\n    pub fn macros_dir() -> PathBuf {\n        match env::var(get_env_name(\"macros_dir\")) {\n            Ok(value) => PathBuf::from(value),\n            Err(_) => Self::local_path(MACROS_DIR_NAME),\n        }\n    }\n\n    pub fn macro_file(name: &str) -> PathBuf {\n        Self::macros_dir().join(format!(\"{name}.yaml\"))\n    }\n\n    pub fn env_file() -> PathBuf {\n        match env::var(get_env_name(\"env_file\")) {\n            Ok(value) => PathBuf::from(value),\n            Err(_) => Self::local_path(ENV_FILE_NAME),\n        }\n    }\n\n    pub fn messages_file(&self) -> PathBuf {\n        match &self.agent {\n            None => match env::var(get_env_name(\"messages_file\")) {\n                Ok(value) => PathBuf::from(value),\n                Err(_) => Self::local_path(MESSAGES_FILE_NAME),\n            },\n            Some(agent) => Self::agent_data_dir(agent.name()).join(MESSAGES_FILE_NAME),\n        }\n    }\n\n    pub fn sessions_dir(&self) -> PathBuf {\n        match &self.agent {\n            None => match env::var(get_env_name(\"sessions_dir\")) {\n                Ok(value) => PathBuf::from(value),\n                Err(_) => Self::local_path(SESSIONS_DIR_NAME),\n            },\n            Some(agent) => Self::agent_data_dir(agent.name()).join(SESSIONS_DIR_NAME),\n        }\n    }\n\n    pub fn rags_dir() -> PathBuf {\n        match env::var(get_env_name(\"rags_dir\")) {\n            Ok(value) => PathBuf::from(value),\n            Err(_) => Self::local_path(RAGS_DIR_NAME),\n        }\n    }\n\n    pub fn functions_dir() -> PathBuf {\n        match env::var(get_env_name(\"functions_dir\")) {\n            Ok(value) => PathBuf::from(value),\n            Err(_) => Self::local_path(FUNCTIONS_DIR_NAME),\n        }\n    }\n\n    pub fn functions_file() -> PathBuf {\n        Self::functions_dir().join(FUNCTIONS_FILE_NAME)\n    }\n\n    pub fn functions_bin_dir() -> PathBuf {\n        Self::functions_dir().join(FUNCTIONS_BIN_DIR_NAME)\n    }\n\n    pub fn session_file(&self, name: &str) -> PathBuf {\n        match name.split_once(\"/\") {\n            Some((dir, name)) => self.sessions_dir().join(dir).join(format!(\"{name}.yaml\")),\n            None => self.sessions_dir().join(format!(\"{name}.yaml\")),\n        }\n    }\n\n    pub fn rag_file(&self, name: &str) -> PathBuf {\n        match &self.agent {\n            Some(agent) => Self::agent_rag_file(agent.name(), name),\n            None => Self::rags_dir().join(format!(\"{name}.yaml\")),\n        }\n    }\n\n    pub fn agents_data_dir() -> PathBuf {\n        Self::local_path(AGENTS_DIR_NAME)\n    }\n\n    pub fn agent_data_dir(name: &str) -> PathBuf {\n        match env::var(format!(\"{}_DATA_DIR\", normalize_env_name(name))) {\n            Ok(value) => PathBuf::from(value),\n            Err(_) => Self::agents_data_dir().join(name),\n        }\n    }\n\n    pub fn agent_config_file(name: &str) -> PathBuf {\n        match env::var(format!(\"{}_CONFIG_FILE\", normalize_env_name(name))) {\n            Ok(value) => PathBuf::from(value),\n            Err(_) => Self::agent_data_dir(name).join(CONFIG_FILE_NAME),\n        }\n    }\n\n    pub fn agent_rag_file(agent_name: &str, rag_name: &str) -> PathBuf {\n        Self::agent_data_dir(agent_name).join(format!(\"{rag_name}.yaml\"))\n    }\n\n    pub fn agents_functions_dir() -> PathBuf {\n        Self::functions_dir().join(AGENTS_DIR_NAME)\n    }\n\n    pub fn agent_functions_dir(name: &str) -> PathBuf {\n        match env::var(format!(\"{}_FUNCTIONS_DIR\", normalize_env_name(name))) {\n            Ok(value) => PathBuf::from(value),\n            Err(_) => Self::agents_functions_dir().join(name),\n        }\n    }\n\n    pub fn models_override_file() -> PathBuf {\n        Self::local_path(\"models-override.yaml\")\n    }\n\n    pub fn state(&self) -> StateFlags {\n        let mut flags = StateFlags::empty();\n        if let Some(session) = &self.session {\n            if session.is_empty() {\n                flags |= StateFlags::SESSION_EMPTY;\n            } else {\n                flags |= StateFlags::SESSION;\n            }\n            if session.role_name().is_some() {\n                flags |= StateFlags::ROLE;\n            }\n        } else if self.role.is_some() {\n            flags |= StateFlags::ROLE;\n        }\n        if self.agent.is_some() {\n            flags |= StateFlags::AGENT;\n        }\n        if self.rag.is_some() {\n            flags |= StateFlags::RAG;\n        }\n        flags\n    }\n\n    pub fn serve_addr(&self) -> String {\n        self.serve_addr.clone().unwrap_or_else(|| SERVE_ADDR.into())\n    }\n\n    pub fn log_config(is_serve: bool) -> Result<(LevelFilter, Option<PathBuf>)> {\n        let log_level = env::var(get_env_name(\"log_level\"))\n            .ok()\n            .and_then(|v| v.parse().ok())\n            .unwrap_or(match cfg!(debug_assertions) {\n                true => LevelFilter::Debug,\n                false => {\n                    if is_serve {\n                        LevelFilter::Info\n                    } else {\n                        LevelFilter::Off\n                    }\n                }\n            });\n        if log_level == LevelFilter::Off {\n            return Ok((log_level, None));\n        }\n        let log_path = match env::var(get_env_name(\"log_path\")) {\n            Ok(v) => Some(PathBuf::from(v)),\n            Err(_) => match is_serve {\n                true => None,\n                false => Some(Config::local_path(&format!(\n                    \"{}.log\",\n                    env!(\"CARGO_CRATE_NAME\")\n                ))),\n            },\n        };\n        Ok((log_level, log_path))\n    }\n\n    pub fn edit_config(&self) -> Result<()> {\n        let config_path = Self::config_file();\n        let editor = self.editor()?;\n        edit_file(&editor, &config_path)?;\n        println!(\n            \"NOTE: Remember to restart {} if there are changes made to '{}\",\n            env!(\"CARGO_CRATE_NAME\"),\n            config_path.display(),\n        );\n        Ok(())\n    }\n\n    pub fn current_model(&self) -> &Model {\n        if let Some(session) = self.session.as_ref() {\n            session.model()\n        } else if let Some(agent) = self.agent.as_ref() {\n            agent.model()\n        } else if let Some(role) = self.role.as_ref() {\n            role.model()\n        } else {\n            &self.model\n        }\n    }\n\n    pub fn role_like_mut(&mut self) -> Option<&mut dyn RoleLike> {\n        if let Some(session) = self.session.as_mut() {\n            Some(session)\n        } else if let Some(agent) = self.agent.as_mut() {\n            Some(agent)\n        } else if let Some(role) = self.role.as_mut() {\n            Some(role)\n        } else {\n            None\n        }\n    }\n\n    pub fn extract_role(&self) -> Role {\n        if let Some(session) = self.session.as_ref() {\n            session.to_role()\n        } else if let Some(agent) = self.agent.as_ref() {\n            agent.to_role()\n        } else if let Some(role) = self.role.as_ref() {\n            role.clone()\n        } else {\n            let mut role = Role::default();\n            role.batch_set(\n                &self.model,\n                self.temperature,\n                self.top_p,\n                self.use_tools.clone(),\n            );\n            role\n        }\n    }\n\n    pub fn info(&self) -> Result<String> {\n        if let Some(agent) = &self.agent {\n            let output = agent.export()?;\n            if let Some(session) = &self.session {\n                let session = session\n                    .export()?\n                    .split('\\n')\n                    .map(|v| format!(\"  {v}\"))\n                    .collect::<Vec<_>>()\n                    .join(\"\\n\");\n                Ok(format!(\"{output}session:\\n{session}\"))\n            } else {\n                Ok(output)\n            }\n        } else if let Some(session) = &self.session {\n            session.export()\n        } else if let Some(role) = &self.role {\n            Ok(role.export())\n        } else if let Some(rag) = &self.rag {\n            rag.export()\n        } else {\n            self.sysinfo()\n        }\n    }\n\n    pub fn sysinfo(&self) -> Result<String> {\n        let display_path = |path: &Path| path.display().to_string();\n        let wrap = self\n            .wrap\n            .clone()\n            .map_or_else(|| String::from(\"no\"), |v| v.to_string());\n        let (rag_reranker_model, rag_top_k) = match &self.rag {\n            Some(rag) => rag.get_config(),\n            None => (self.rag_reranker_model.clone(), self.rag_top_k),\n        };\n        let role = self.extract_role();\n        let mut items = vec![\n            (\"model\", role.model().id()),\n            (\"temperature\", format_option_value(&role.temperature())),\n            (\"top_p\", format_option_value(&role.top_p())),\n            (\"use_tools\", format_option_value(&role.use_tools())),\n            (\n                \"max_output_tokens\",\n                role.model()\n                    .max_tokens_param()\n                    .map(|v| format!(\"{v} (current model)\"))\n                    .unwrap_or_else(|| \"null\".into()),\n            ),\n            (\"save_session\", format_option_value(&self.save_session)),\n            (\"compress_threshold\", self.compress_threshold.to_string()),\n            (\n                \"rag_reranker_model\",\n                format_option_value(&rag_reranker_model),\n            ),\n            (\"rag_top_k\", rag_top_k.to_string()),\n            (\"dry_run\", self.dry_run.to_string()),\n            (\"function_calling\", self.function_calling.to_string()),\n            (\"stream\", self.stream.to_string()),\n            (\"save\", self.save.to_string()),\n            (\"keybindings\", self.keybindings.clone()),\n            (\"wrap\", wrap),\n            (\"wrap_code\", self.wrap_code.to_string()),\n            (\"highlight\", self.highlight.to_string()),\n            (\"theme\", format_option_value(&self.theme)),\n            (\"config_file\", display_path(&Self::config_file())),\n            (\"env_file\", display_path(&Self::env_file())),\n            (\"roles_dir\", display_path(&Self::roles_dir())),\n            (\"sessions_dir\", display_path(&self.sessions_dir())),\n            (\"rags_dir\", display_path(&Self::rags_dir())),\n            (\"macros_dir\", display_path(&Self::macros_dir())),\n            (\"functions_dir\", display_path(&Self::functions_dir())),\n            (\"messages_file\", display_path(&self.messages_file())),\n        ];\n        if let Ok((_, Some(log_path))) = Self::log_config(self.working_mode.is_serve()) {\n            items.push((\"log_path\", display_path(&log_path)));\n        }\n        let output = items\n            .iter()\n            .map(|(name, value)| format!(\"{name:<24}{value}\\n\"))\n            .collect::<Vec<String>>()\n            .join(\"\");\n        Ok(output)\n    }\n\n    pub fn update(config: &GlobalConfig, data: &str) -> Result<()> {\n        let parts: Vec<&str> = data.split_whitespace().collect();\n        if parts.len() != 2 {\n            bail!(\"Usage: .set <key> <value>. If value is null, unset key.\");\n        }\n        let key = parts[0];\n        let value = parts[1];\n        match key {\n            \"temperature\" => {\n                let value = parse_value(value)?;\n                config.write().set_temperature(value);\n            }\n            \"top_p\" => {\n                let value = parse_value(value)?;\n                config.write().set_top_p(value);\n            }\n            \"use_tools\" => {\n                let value = parse_value(value)?;\n                config.write().set_use_tools(value);\n            }\n            \"max_output_tokens\" => {\n                let value = parse_value(value)?;\n                config.write().set_max_output_tokens(value);\n            }\n            \"save_session\" => {\n                let value = parse_value(value)?;\n                config.write().set_save_session(value);\n            }\n            \"compress_threshold\" => {\n                let value = parse_value(value)?;\n                config.write().set_compress_threshold(value);\n            }\n            \"rag_reranker_model\" => {\n                let value = parse_value(value)?;\n                Self::set_rag_reranker_model(config, value)?;\n            }\n            \"rag_top_k\" => {\n                let value = value.parse().with_context(|| \"Invalid value\")?;\n                Self::set_rag_top_k(config, value)?;\n            }\n            \"dry_run\" => {\n                let value = value.parse().with_context(|| \"Invalid value\")?;\n                config.write().dry_run = value;\n            }\n            \"function_calling\" => {\n                let value = value.parse().with_context(|| \"Invalid value\")?;\n                if value && config.write().functions.is_empty() {\n                    bail!(\"Function calling cannot be enabled because no functions are installed.\")\n                }\n                config.write().function_calling = value;\n            }\n            \"stream\" => {\n                let value = value.parse().with_context(|| \"Invalid value\")?;\n                config.write().stream = value;\n            }\n            \"save\" => {\n                let value = value.parse().with_context(|| \"Invalid value\")?;\n                config.write().save = value;\n            }\n            \"highlight\" => {\n                let value = value.parse().with_context(|| \"Invalid value\")?;\n                config.write().highlight = value;\n            }\n            _ => bail!(\"Unknown key '{key}'\"),\n        }\n        Ok(())\n    }\n\n    pub fn delete(config: &GlobalConfig, kind: &str) -> Result<()> {\n        let (dir, file_ext) = match kind {\n            \"role\" => (Self::roles_dir(), Some(\".md\")),\n            \"session\" => (config.read().sessions_dir(), Some(\".yaml\")),\n            \"rag\" => (Self::rags_dir(), Some(\".yaml\")),\n            \"macro\" => (Self::macros_dir(), Some(\".yaml\")),\n            \"agent-data\" => (Self::agents_data_dir(), None),\n            _ => bail!(\"Unknown kind '{kind}'\"),\n        };\n        let names = match read_dir(&dir) {\n            Ok(rd) => {\n                let mut names = vec![];\n                for entry in rd.flatten() {\n                    let name = entry.file_name();\n                    match file_ext {\n                        Some(file_ext) => {\n                            if let Some(name) = name.to_string_lossy().strip_suffix(file_ext) {\n                                names.push(name.to_string());\n                            }\n                        }\n                        None => {\n                            if entry.path().is_dir() {\n                                names.push(name.to_string_lossy().to_string());\n                            }\n                        }\n                    }\n                }\n                names.sort_unstable();\n                names\n            }\n            Err(_) => vec![],\n        };\n\n        if names.is_empty() {\n            bail!(\"No {kind} to delete\")\n        }\n\n        let select_names = MultiSelect::new(&format!(\"Select {kind} to delete:\"), names)\n            .with_validator(|list: &[ListOption<&String>]| {\n                if list.is_empty() {\n                    Ok(Validation::Invalid(\n                        \"At least one item must be selected\".into(),\n                    ))\n                } else {\n                    Ok(Validation::Valid)\n                }\n            })\n            .prompt()?;\n\n        for name in select_names {\n            match file_ext {\n                Some(ext) => {\n                    let path = dir.join(format!(\"{name}{ext}\"));\n                    remove_file(&path).with_context(|| {\n                        format!(\"Failed to delete {kind} at '{}'\", path.display())\n                    })?;\n                }\n                None => {\n                    let path = dir.join(name);\n                    remove_dir_all(&path).with_context(|| {\n                        format!(\"Failed to delete {kind} at '{}'\", path.display())\n                    })?;\n                }\n            }\n        }\n        println!(\"✓ Successfully deleted {kind}.\");\n        Ok(())\n    }\n\n    pub fn set_temperature(&mut self, value: Option<f64>) {\n        match self.role_like_mut() {\n            Some(role_like) => role_like.set_temperature(value),\n            None => self.temperature = value,\n        }\n    }\n\n    pub fn set_top_p(&mut self, value: Option<f64>) {\n        match self.role_like_mut() {\n            Some(role_like) => role_like.set_top_p(value),\n            None => self.top_p = value,\n        }\n    }\n\n    pub fn set_use_tools(&mut self, value: Option<String>) {\n        match self.role_like_mut() {\n            Some(role_like) => role_like.set_use_tools(value),\n            None => self.use_tools = value,\n        }\n    }\n\n    pub fn set_save_session(&mut self, value: Option<bool>) {\n        if let Some(session) = self.session.as_mut() {\n            session.set_save_session(value);\n        } else {\n            self.save_session = value;\n        }\n    }\n\n    pub fn set_compress_threshold(&mut self, value: Option<usize>) {\n        if let Some(session) = self.session.as_mut() {\n            session.set_compress_threshold(value);\n        } else {\n            self.compress_threshold = value.unwrap_or_default();\n        }\n    }\n\n    pub fn set_rag_reranker_model(config: &GlobalConfig, value: Option<String>) -> Result<()> {\n        if let Some(id) = &value {\n            Model::retrieve_model(&config.read(), id, ModelType::Reranker)?;\n        }\n        let has_rag = config.read().rag.is_some();\n        match has_rag {\n            true => update_rag(config, |rag| {\n                rag.set_reranker_model(value)?;\n                Ok(())\n            })?,\n            false => config.write().rag_reranker_model = value,\n        }\n        Ok(())\n    }\n\n    pub fn set_rag_top_k(config: &GlobalConfig, value: usize) -> Result<()> {\n        let has_rag = config.read().rag.is_some();\n        match has_rag {\n            true => update_rag(config, |rag| {\n                rag.set_top_k(value)?;\n                Ok(())\n            })?,\n            false => config.write().rag_top_k = value,\n        }\n        Ok(())\n    }\n\n    pub fn set_wrap(&mut self, value: &str) -> Result<()> {\n        if value == \"no\" {\n            self.wrap = None;\n        } else if value == \"auto\" {\n            self.wrap = Some(value.into());\n        } else {\n            value\n                .parse::<u16>()\n                .map_err(|_| anyhow!(\"Invalid wrap value\"))?;\n            self.wrap = Some(value.into())\n        }\n        Ok(())\n    }\n\n    pub fn set_max_output_tokens(&mut self, value: Option<isize>) {\n        match self.role_like_mut() {\n            Some(role_like) => {\n                let mut model = role_like.model().clone();\n                model.set_max_tokens(value, true);\n                role_like.set_model(model);\n            }\n            None => {\n                self.model.set_max_tokens(value, true);\n            }\n        };\n    }\n\n    pub fn set_model(&mut self, model_id: &str) -> Result<()> {\n        let model = Model::retrieve_model(self, model_id, ModelType::Chat)?;\n        match self.role_like_mut() {\n            Some(role_like) => role_like.set_model(model),\n            None => {\n                self.model = model;\n            }\n        }\n        Ok(())\n    }\n\n    pub fn use_prompt(&mut self, prompt: &str) -> Result<()> {\n        let mut role = Role::new(TEMP_ROLE_NAME, prompt);\n        role.set_model(self.current_model().clone());\n        self.use_role_obj(role)\n    }\n\n    pub fn use_role(&mut self, name: &str) -> Result<()> {\n        let role = self.retrieve_role(name)?;\n        self.use_role_obj(role)\n    }\n\n    pub fn use_role_obj(&mut self, role: Role) -> Result<()> {\n        if self.agent.is_some() {\n            bail!(\"Cannot perform this operation because you are using a agent\")\n        }\n        if let Some(session) = self.session.as_mut() {\n            session.guard_empty()?;\n            session.set_role(role);\n        } else {\n            self.role = Some(role);\n        }\n        Ok(())\n    }\n\n    pub fn role_info(&self) -> Result<String> {\n        if let Some(session) = &self.session {\n            if session.role_name().is_some() {\n                let role = session.to_role();\n                Ok(role.export())\n            } else {\n                bail!(\"No session role\")\n            }\n        } else if let Some(role) = &self.role {\n            Ok(role.export())\n        } else {\n            bail!(\"No role\")\n        }\n    }\n\n    pub fn exit_role(&mut self) -> Result<()> {\n        if let Some(session) = self.session.as_mut() {\n            session.guard_empty()?;\n            session.clear_role();\n        } else if self.role.is_some() {\n            self.role = None;\n        }\n        Ok(())\n    }\n\n    pub fn retrieve_role(&self, name: &str) -> Result<Role> {\n        let names = Self::list_roles(false);\n        let mut role = if names.contains(&name.to_string()) {\n            let path = Self::role_file(name);\n            let content = read_to_string(&path)?;\n            Role::new(name, &content)\n        } else {\n            Role::builtin(name)?\n        };\n        let current_model = self.current_model().clone();\n        match role.model_id() {\n            Some(model_id) => {\n                if current_model.id() != model_id {\n                    let model = Model::retrieve_model(self, model_id, ModelType::Chat)?;\n                    role.set_model(model);\n                } else {\n                    role.set_model(current_model);\n                }\n            }\n            None => {\n                role.set_model(current_model);\n                if role.temperature().is_none() {\n                    role.set_temperature(self.temperature);\n                }\n                if role.top_p().is_none() {\n                    role.set_top_p(self.top_p);\n                }\n            }\n        }\n        Ok(role)\n    }\n\n    pub fn new_role(&mut self, name: &str) -> Result<()> {\n        if self.macro_flag {\n            bail!(\"No role\");\n        }\n        let ans = Confirm::new(\"Create a new role?\")\n            .with_default(true)\n            .prompt()?;\n        if ans {\n            self.upsert_role(name)?;\n        } else {\n            bail!(\"No role\");\n        }\n        Ok(())\n    }\n\n    pub fn edit_role(&mut self) -> Result<()> {\n        let role_name;\n        if let Some(session) = self.session.as_ref() {\n            if let Some(name) = session.role_name().map(|v| v.to_string()) {\n                if session.is_empty() {\n                    role_name = Some(name);\n                } else {\n                    bail!(\"Cannot perform this operation because you are in a non-empty session\")\n                }\n            } else {\n                bail!(\"No role\")\n            }\n        } else {\n            role_name = self.role.as_ref().map(|v| v.name().to_string());\n        }\n        let name = role_name.ok_or_else(|| anyhow!(\"No role\"))?;\n        self.upsert_role(&name)?;\n        self.use_role(&name)\n    }\n\n    pub fn upsert_role(&mut self, name: &str) -> Result<()> {\n        let role_path = Self::role_file(name);\n        ensure_parent_exists(&role_path)?;\n        let editor = self.editor()?;\n        edit_file(&editor, &role_path)?;\n        if self.working_mode.is_repl() {\n            println!(\"✓ Saved the role to '{}'.\", role_path.display());\n        }\n        Ok(())\n    }\n\n    pub fn save_role(&mut self, name: Option<&str>) -> Result<()> {\n        let mut role_name = match &self.role {\n            Some(role) => {\n                if role.has_args() {\n                    bail!(\"Unable to save the role with arguments (whose name contains '#')\")\n                }\n                match name {\n                    Some(v) => v.to_string(),\n                    None => role.name().to_string(),\n                }\n            }\n            None => bail!(\"No role\"),\n        };\n        if role_name == TEMP_ROLE_NAME {\n            role_name = Text::new(\"Role name:\")\n                .with_validator(|input: &str| {\n                    let input = input.trim();\n                    if input.is_empty() {\n                        Ok(Validation::Invalid(\"This name is required\".into()))\n                    } else if input == TEMP_ROLE_NAME {\n                        Ok(Validation::Invalid(\"This name is reserved\".into()))\n                    } else {\n                        Ok(Validation::Valid)\n                    }\n                })\n                .prompt()?;\n        }\n        let role_path = Self::role_file(&role_name);\n        if let Some(role) = self.role.as_mut() {\n            role.save(&role_name, &role_path, self.working_mode.is_repl())?;\n        }\n\n        Ok(())\n    }\n\n    pub fn all_roles() -> Vec<Role> {\n        let mut roles: HashMap<String, Role> = Role::list_builtin_roles()\n            .iter()\n            .map(|v| (v.name().to_string(), v.clone()))\n            .collect();\n        let names = Self::list_roles(false);\n        for name in names {\n            if let Ok(content) = read_to_string(Self::role_file(&name)) {\n                let role = Role::new(&name, &content);\n                roles.insert(name, role);\n            }\n        }\n        let mut roles: Vec<_> = roles.into_values().collect();\n        roles.sort_unstable_by(|a, b| a.name().cmp(b.name()));\n        roles\n    }\n\n    pub fn list_roles(with_builtin: bool) -> Vec<String> {\n        let mut names = HashSet::new();\n        if let Ok(rd) = read_dir(Self::roles_dir()) {\n            for entry in rd.flatten() {\n                if let Some(name) = entry\n                    .file_name()\n                    .to_str()\n                    .and_then(|v| v.strip_suffix(\".md\"))\n                {\n                    names.insert(name.to_string());\n                }\n            }\n        }\n        if with_builtin {\n            names.extend(Role::list_builtin_role_names());\n        }\n        let mut names: Vec<_> = names.into_iter().collect();\n        names.sort_unstable();\n        names\n    }\n\n    pub fn has_role(name: &str) -> bool {\n        let names = Self::list_roles(true);\n        names.contains(&name.to_string())\n    }\n\n    pub fn use_session(&mut self, session_name: Option<&str>) -> Result<()> {\n        if self.session.is_some() {\n            bail!(\n                \"Already in a session, please run '.exit session' first to exit the current session.\"\n            );\n        }\n        let mut session;\n        match session_name {\n            None | Some(TEMP_SESSION_NAME) => {\n                let session_file = self.session_file(TEMP_SESSION_NAME);\n                if session_file.exists() {\n                    remove_file(session_file).with_context(|| {\n                        format!(\"Failed to cleanup previous '{TEMP_SESSION_NAME}' session\")\n                    })?;\n                }\n                session = Some(Session::new(self, TEMP_SESSION_NAME));\n            }\n            Some(name) => {\n                let session_path = self.session_file(name);\n                if !session_path.exists() {\n                    session = Some(Session::new(self, name));\n                } else {\n                    session = Some(Session::load(self, name, &session_path)?);\n                }\n            }\n        }\n        let mut new_session = false;\n        if let Some(session) = session.as_mut() {\n            if session.is_empty() {\n                new_session = true;\n                if let Some(LastMessage {\n                    input,\n                    output,\n                    continuous,\n                }) = &self.last_message\n                {\n                    if (*continuous && !output.is_empty())\n                        && self.agent.is_some() == input.with_agent()\n                    {\n                        let ans = Confirm::new(\n                            \"Start a session that incorporates the last question and answer?\",\n                        )\n                        .with_default(false)\n                        .prompt()?;\n                        if ans {\n                            session.add_message(input, output)?;\n                        }\n                    }\n                }\n            }\n        }\n        self.session = session;\n        self.init_agent_session_variables(new_session)?;\n        Ok(())\n    }\n\n    pub fn session_info(&self) -> Result<String> {\n        if let Some(session) = &self.session {\n            let render_options = self.render_options()?;\n            let mut markdown_render = MarkdownRender::init(render_options)?;\n            let agent_info: Option<(String, Vec<String>)> = self.agent.as_ref().map(|agent| {\n                let functions = agent\n                    .functions()\n                    .declarations()\n                    .iter()\n                    .filter_map(|v| if v.agent { Some(v.name.clone()) } else { None })\n                    .collect();\n                (agent.name().to_string(), functions)\n            });\n            session.render(&mut markdown_render, &agent_info)\n        } else {\n            bail!(\"No session\")\n        }\n    }\n\n    pub fn exit_session(&mut self) -> Result<()> {\n        if let Some(mut session) = self.session.take() {\n            let sessions_dir = self.sessions_dir();\n            session.exit(&sessions_dir, self.working_mode.is_repl())?;\n            self.discontinuous_last_message();\n        }\n        Ok(())\n    }\n\n    pub fn save_session(&mut self, name: Option<&str>) -> Result<()> {\n        let session_name = match &self.session {\n            Some(session) => match name {\n                Some(v) => v.to_string(),\n                None => session\n                    .autoname()\n                    .unwrap_or_else(|| session.name())\n                    .to_string(),\n            },\n            None => bail!(\"No session\"),\n        };\n        let session_path = self.session_file(&session_name);\n        if let Some(session) = self.session.as_mut() {\n            session.save(&session_name, &session_path, self.working_mode.is_repl())?;\n        }\n        Ok(())\n    }\n\n    pub fn edit_session(&mut self) -> Result<()> {\n        let name = match &self.session {\n            Some(session) => session.name().to_string(),\n            None => bail!(\"No session\"),\n        };\n        let session_path = self.session_file(&name);\n        self.save_session(Some(&name))?;\n        let editor = self.editor()?;\n        edit_file(&editor, &session_path).with_context(|| {\n            format!(\n                \"Failed to edit '{}' with '{editor}'\",\n                session_path.display()\n            )\n        })?;\n        self.session = Some(Session::load(self, &name, &session_path)?);\n        self.discontinuous_last_message();\n        Ok(())\n    }\n\n    pub fn empty_session(&mut self) -> Result<()> {\n        if let Some(session) = self.session.as_mut() {\n            if let Some(agent) = self.agent.as_ref() {\n                session.sync_agent(agent);\n            }\n            session.clear_messages();\n        } else {\n            bail!(\"No session\")\n        }\n        self.discontinuous_last_message();\n        Ok(())\n    }\n\n    pub fn set_save_session_this_time(&mut self) -> Result<()> {\n        if let Some(session) = self.session.as_mut() {\n            session.set_save_session_this_time();\n        } else {\n            bail!(\"No session\")\n        }\n        Ok(())\n    }\n\n    pub fn list_sessions(&self) -> Vec<String> {\n        list_file_names(self.sessions_dir(), \".yaml\")\n    }\n\n    pub fn list_autoname_sessions(&self) -> Vec<String> {\n        list_file_names(self.sessions_dir().join(\"_\"), \".yaml\")\n    }\n\n    pub fn maybe_compress_session(config: GlobalConfig) {\n        let mut need_compress = false;\n        {\n            let mut config = config.write();\n            let compress_threshold = config.compress_threshold;\n            if let Some(session) = config.session.as_mut() {\n                if session.need_compress(compress_threshold) {\n                    session.set_compressing(true);\n                    need_compress = true;\n                }\n            }\n        };\n        if !need_compress {\n            return;\n        }\n        let color = if config.read().light_theme() {\n            nu_ansi_term::Color::LightGray\n        } else {\n            nu_ansi_term::Color::DarkGray\n        };\n        print!(\n            \"\\n📢 {}\\n\",\n            color.italic().paint(\"Compressing the session.\"),\n        );\n        tokio::spawn(async move {\n            if let Err(err) = Config::compress_session(&config).await {\n                warn!(\"Failed to compress the session: {err}\");\n            }\n            if let Some(session) = config.write().session.as_mut() {\n                session.set_compressing(false);\n            }\n        });\n    }\n\n    pub async fn compress_session(config: &GlobalConfig) -> Result<()> {\n        match config.read().session.as_ref() {\n            Some(session) => {\n                if !session.has_user_messages() {\n                    bail!(\"No need to compress since there are no messages in the session\")\n                }\n            }\n            None => bail!(\"No session\"),\n        }\n\n        let prompt = config\n            .read()\n            .summarize_prompt\n            .clone()\n            .unwrap_or_else(|| SUMMARIZE_PROMPT.into());\n        let input = Input::from_str(config, &prompt, None);\n        let summary = input.fetch_chat_text().await?;\n        let summary_prompt = config\n            .read()\n            .summary_prompt\n            .clone()\n            .unwrap_or_else(|| SUMMARY_PROMPT.into());\n        if let Some(session) = config.write().session.as_mut() {\n            session.compress(format!(\"{summary_prompt}{summary}\"));\n        }\n        config.write().discontinuous_last_message();\n        Ok(())\n    }\n\n    pub fn is_compressing_session(&self) -> bool {\n        self.session\n            .as_ref()\n            .map(|v| v.compressing())\n            .unwrap_or_default()\n    }\n\n    pub fn maybe_autoname_session(config: GlobalConfig) {\n        let mut need_autoname = false;\n        if let Some(session) = config.write().session.as_mut() {\n            if session.need_autoname() {\n                session.set_autonaming(true);\n                need_autoname = true;\n            }\n        }\n        if !need_autoname {\n            return;\n        }\n        let color = if config.read().light_theme() {\n            nu_ansi_term::Color::LightGray\n        } else {\n            nu_ansi_term::Color::DarkGray\n        };\n        print!(\"\\n📢 {}\\n\", color.italic().paint(\"Autonaming the session.\"),);\n        tokio::spawn(async move {\n            if let Err(err) = Config::autoname_session(&config).await {\n                warn!(\"Failed to autonaming the session: {err}\");\n            }\n            if let Some(session) = config.write().session.as_mut() {\n                session.set_autonaming(false);\n            }\n        });\n    }\n\n    pub async fn autoname_session(config: &GlobalConfig) -> Result<()> {\n        let text = match config\n            .read()\n            .session\n            .as_ref()\n            .and_then(|v| v.chat_history_for_autonaming())\n        {\n            Some(v) => v,\n            None => bail!(\"No chat history\"),\n        };\n        let role = config.read().retrieve_role(CREATE_TITLE_ROLE)?;\n        let input = Input::from_str(config, &text, Some(role));\n        let text = input.fetch_chat_text().await?;\n        if let Some(session) = config.write().session.as_mut() {\n            session.set_autoname(&text);\n        }\n        Ok(())\n    }\n\n    pub async fn use_rag(\n        config: &GlobalConfig,\n        rag: Option<&str>,\n        abort_signal: AbortSignal,\n    ) -> Result<()> {\n        if config.read().agent.is_some() {\n            bail!(\"Cannot perform this operation because you are using a agent\")\n        }\n        let rag = match rag {\n            None => {\n                let rag_path = config.read().rag_file(TEMP_RAG_NAME);\n                if rag_path.exists() {\n                    remove_file(&rag_path).with_context(|| {\n                        format!(\"Failed to cleanup previous '{TEMP_RAG_NAME}' rag\")\n                    })?;\n                }\n                Rag::init(config, TEMP_RAG_NAME, &rag_path, &[], abort_signal).await?\n            }\n            Some(name) => {\n                let rag_path = config.read().rag_file(name);\n                if !rag_path.exists() {\n                    if config.read().working_mode.is_cmd() {\n                        bail!(\"Unknown RAG '{name}'\")\n                    }\n                    Rag::init(config, name, &rag_path, &[], abort_signal).await?\n                } else {\n                    Rag::load(config, name, &rag_path)?\n                }\n            }\n        };\n        config.write().rag = Some(Arc::new(rag));\n        Ok(())\n    }\n\n    pub async fn edit_rag_docs(config: &GlobalConfig, abort_signal: AbortSignal) -> Result<()> {\n        let mut rag = match config.read().rag.clone() {\n            Some(v) => v.as_ref().clone(),\n            None => bail!(\"No RAG\"),\n        };\n\n        let document_paths = rag.document_paths();\n        let temp_file = temp_file(&format!(\"-rag-{}\", rag.name()), \".txt\");\n        tokio::fs::write(&temp_file, &document_paths.join(\"\\n\"))\n            .await\n            .with_context(|| format!(\"Failed to write to '{}'\", temp_file.display()))?;\n        let editor = config.read().editor()?;\n        edit_file(&editor, &temp_file)?;\n        let new_document_paths = tokio::fs::read_to_string(&temp_file)\n            .await\n            .with_context(|| format!(\"Failed to read '{}'\", temp_file.display()))?;\n        let new_document_paths = new_document_paths\n            .split('\\n')\n            .filter_map(|v| {\n                let v = v.trim();\n                if v.is_empty() {\n                    None\n                } else {\n                    Some(v.to_string())\n                }\n            })\n            .collect::<Vec<_>>();\n        if new_document_paths.is_empty() || new_document_paths == document_paths {\n            bail!(\"No changes\")\n        }\n        rag.refresh_document_paths(&new_document_paths, false, config, abort_signal)\n            .await?;\n        config.write().rag = Some(Arc::new(rag));\n        Ok(())\n    }\n\n    pub async fn rebuild_rag(config: &GlobalConfig, abort_signal: AbortSignal) -> Result<()> {\n        let mut rag = match config.read().rag.clone() {\n            Some(v) => v.as_ref().clone(),\n            None => bail!(\"No RAG\"),\n        };\n        let document_paths = rag.document_paths().to_vec();\n        rag.refresh_document_paths(&document_paths, true, config, abort_signal)\n            .await?;\n        config.write().rag = Some(Arc::new(rag));\n        Ok(())\n    }\n\n    pub fn rag_sources(config: &GlobalConfig) -> Result<String> {\n        match config.read().rag.as_ref() {\n            Some(rag) => match rag.get_last_sources() {\n                Some(v) => Ok(v),\n                None => bail!(\"No sources\"),\n            },\n            None => bail!(\"No RAG\"),\n        }\n    }\n\n    pub fn rag_info(&self) -> Result<String> {\n        if let Some(rag) = &self.rag {\n            rag.export()\n        } else {\n            bail!(\"No RAG\")\n        }\n    }\n\n    pub fn exit_rag(&mut self) -> Result<()> {\n        self.rag.take();\n        Ok(())\n    }\n\n    pub async fn search_rag(\n        config: &GlobalConfig,\n        rag: &Rag,\n        text: &str,\n        abort_signal: AbortSignal,\n    ) -> Result<String> {\n        let (reranker_model, top_k) = rag.get_config();\n        let (embeddings, ids) = rag\n            .search(text, top_k, reranker_model.as_deref(), abort_signal)\n            .await?;\n        let text = config.read().rag_template(&embeddings, text);\n        rag.set_last_sources(&ids);\n        Ok(text)\n    }\n\n    pub fn list_rags() -> Vec<String> {\n        match read_dir(Self::rags_dir()) {\n            Ok(rd) => {\n                let mut names = vec![];\n                for entry in rd.flatten() {\n                    let name = entry.file_name();\n                    if let Some(name) = name.to_string_lossy().strip_suffix(\".yaml\") {\n                        names.push(name.to_string());\n                    }\n                }\n                names.sort_unstable();\n                names\n            }\n            Err(_) => vec![],\n        }\n    }\n\n    pub fn rag_template(&self, embeddings: &str, text: &str) -> String {\n        if embeddings.is_empty() {\n            return text.to_string();\n        }\n        self.rag_template\n            .as_deref()\n            .unwrap_or(RAG_TEMPLATE)\n            .replace(\"__CONTEXT__\", embeddings)\n            .replace(\"__INPUT__\", text)\n    }\n\n    pub async fn use_agent(\n        config: &GlobalConfig,\n        agent_name: &str,\n        session_name: Option<&str>,\n        abort_signal: AbortSignal,\n    ) -> Result<()> {\n        if !config.read().function_calling {\n            bail!(\"Please enable function calling before using the agent.\");\n        }\n        if config.read().agent.is_some() {\n            bail!(\"Already in a agent, please run '.exit agent' first to exit the current agent.\");\n        }\n        let agent = Agent::init(config, agent_name, abort_signal).await?;\n        let session = session_name.map(|v| v.to_string()).or_else(|| {\n            if config.read().macro_flag {\n                None\n            } else {\n                agent.agent_prelude().map(|v| v.to_string())\n            }\n        });\n        config.write().rag = agent.rag();\n        config.write().agent = Some(agent);\n        if let Some(session) = session {\n            config.write().use_session(Some(&session))?;\n        } else {\n            config.write().init_agent_shared_variables()?;\n        }\n        Ok(())\n    }\n\n    pub fn agent_info(&self) -> Result<String> {\n        if let Some(agent) = &self.agent {\n            agent.export()\n        } else {\n            bail!(\"No agent\")\n        }\n    }\n\n    pub fn agent_banner(&self) -> Result<String> {\n        if let Some(agent) = &self.agent {\n            Ok(agent.banner())\n        } else {\n            bail!(\"No agent\")\n        }\n    }\n\n    pub fn edit_agent_config(&self) -> Result<()> {\n        let agent_name = match &self.agent {\n            Some(agent) => agent.name(),\n            None => bail!(\"No agent\"),\n        };\n        let agent_config_path = Config::agent_config_file(agent_name);\n        ensure_parent_exists(&agent_config_path)?;\n        if !agent_config_path.exists() {\n            std::fs::write(\n                &agent_config_path,\n                \"# see https://github.com/sigoden/aichat/blob/main/config.agent.example.yaml\\n\",\n            )\n            .with_context(|| format!(\"Failed to write to '{}'\", agent_config_path.display()))?;\n        }\n        let editor = self.editor()?;\n        edit_file(&editor, &agent_config_path)?;\n        println!(\n            \"NOTE: Remember to reload the agent if there are changes made to '{}'\",\n            agent_config_path.display()\n        );\n        Ok(())\n    }\n\n    pub fn exit_agent(&mut self) -> Result<()> {\n        self.exit_session()?;\n        if self.agent.take().is_some() {\n            self.rag.take();\n            self.discontinuous_last_message();\n        }\n        Ok(())\n    }\n\n    pub fn exit_agent_session(&mut self) -> Result<()> {\n        self.exit_session()?;\n        if let Some(agent) = self.agent.as_mut() {\n            agent.exit_session();\n            if self.working_mode.is_repl() {\n                self.init_agent_shared_variables()?;\n            }\n        }\n        Ok(())\n    }\n\n    pub fn list_macros() -> Vec<String> {\n        list_file_names(Self::macros_dir(), \".yaml\")\n    }\n\n    pub fn load_macro(name: &str) -> Result<Macro> {\n        let path = Self::macro_file(name);\n        let err = || format!(\"Failed to load macro '{name}' at '{}'\", path.display());\n        let content = read_to_string(&path).with_context(err)?;\n        let value: Macro = serde_yaml::from_str(&content).with_context(err)?;\n        Ok(value)\n    }\n\n    pub fn has_macro(name: &str) -> bool {\n        let names = Self::list_macros();\n        names.contains(&name.to_string())\n    }\n\n    pub fn new_macro(&mut self, name: &str) -> Result<()> {\n        if self.macro_flag {\n            bail!(\"No macro\");\n        }\n        let ans = Confirm::new(\"Create a new macro?\")\n            .with_default(true)\n            .prompt()?;\n        if ans {\n            let macro_path = Self::macro_file(name);\n            ensure_parent_exists(&macro_path)?;\n            let editor = self.editor()?;\n            edit_file(&editor, &macro_path)?;\n        } else {\n            bail!(\"No macro\");\n        }\n        Ok(())\n    }\n\n    pub fn apply_prelude(&mut self) -> Result<()> {\n        if self.macro_flag || !self.state().is_empty() {\n            return Ok(());\n        }\n        let prelude = match self.working_mode {\n            WorkingMode::Repl => self.repl_prelude.as_ref(),\n            WorkingMode::Cmd => self.cmd_prelude.as_ref(),\n            WorkingMode::Serve => return Ok(()),\n        };\n        let prelude = match prelude {\n            Some(v) => {\n                if v.is_empty() {\n                    return Ok(());\n                }\n                v.to_string()\n            }\n            None => return Ok(()),\n        };\n\n        let err_msg = || format!(\"Invalid prelude '{prelude}\");\n        match prelude.split_once(':') {\n            Some((\"role\", name)) => {\n                self.use_role(name).with_context(err_msg)?;\n            }\n            Some((\"session\", name)) => {\n                self.use_session(Some(name)).with_context(err_msg)?;\n            }\n            Some((session_name, role_name)) => {\n                self.use_session(Some(session_name)).with_context(err_msg)?;\n                if let Some(true) = self.session.as_ref().map(|v| v.is_empty()) {\n                    self.use_role(role_name).with_context(err_msg)?;\n                }\n            }\n            _ => {\n                bail!(\"{}\", err_msg())\n            }\n        }\n        Ok(())\n    }\n\n    pub fn select_functions(&self, role: &Role) -> Option<Vec<FunctionDeclaration>> {\n        let mut functions = vec![];\n        if self.function_calling {\n            if let Some(use_tools) = role.use_tools() {\n                let mut tool_names: HashSet<String> = Default::default();\n                let declaration_names: HashSet<String> = self\n                    .functions\n                    .declarations()\n                    .iter()\n                    .map(|v| v.name.to_string())\n                    .collect();\n                if use_tools == \"all\" {\n                    tool_names.extend(declaration_names);\n                } else {\n                    for item in use_tools.split(',') {\n                        let item = item.trim();\n                        if let Some(values) = self.mapping_tools.get(item) {\n                            tool_names.extend(\n                                values\n                                    .split(',')\n                                    .map(|v| v.to_string())\n                                    .filter(|v| declaration_names.contains(v)),\n                            )\n                        } else if declaration_names.contains(item) {\n                            tool_names.insert(item.to_string());\n                        }\n                    }\n                }\n                functions = self\n                    .functions\n                    .declarations()\n                    .iter()\n                    .filter_map(|v| {\n                        if tool_names.contains(&v.name) {\n                            Some(v.clone())\n                        } else {\n                            None\n                        }\n                    })\n                    .collect();\n            }\n\n            if let Some(agent) = &self.agent {\n                let mut agent_functions = agent.functions().declarations().to_vec();\n                let tool_names: HashSet<String> = agent_functions\n                    .iter()\n                    .filter_map(|v| {\n                        if v.agent {\n                            None\n                        } else {\n                            Some(v.name.to_string())\n                        }\n                    })\n                    .collect();\n                agent_functions.extend(\n                    functions\n                        .into_iter()\n                        .filter(|v| !tool_names.contains(&v.name)),\n                );\n                functions = agent_functions;\n            }\n        };\n        if functions.is_empty() {\n            None\n        } else {\n            Some(functions)\n        }\n    }\n\n    pub fn editor(&self) -> Result<String> {\n        EDITOR.get_or_init(move || {\n            let editor = self.editor.clone()\n                .or_else(|| env::var(\"VISUAL\").ok().or_else(|| env::var(\"EDITOR\").ok()))\n                .unwrap_or_else(|| {\n                    if cfg!(windows) {\n                        \"notepad\".to_string()\n                    } else {\n                        \"nano\".to_string()\n                    }\n                });\n            which::which(&editor).ok().map(|_| editor)\n        })\n        .clone()\n        .ok_or_else(|| anyhow!(\"Editor not found. Please add the `editor` configuration or set the $EDITOR or $VISUAL environment variable.\"))\n    }\n\n    pub fn repl_complete(\n        &self,\n        cmd: &str,\n        args: &[&str],\n        _line: &str,\n    ) -> Vec<(String, Option<String>)> {\n        let mut values: Vec<(String, Option<String>)> = vec![];\n        let filter = args.last().unwrap_or(&\"\");\n        if args.len() == 1 {\n            values = match cmd {\n                \".role\" => map_completion_values(Self::list_roles(true)),\n                \".model\" => list_models(self, ModelType::Chat)\n                    .into_iter()\n                    .map(|v| (v.id(), Some(v.description())))\n                    .collect(),\n                \".session\" => {\n                    if args[0].starts_with(\"_/\") {\n                        map_completion_values(\n                            self.list_autoname_sessions()\n                                .iter()\n                                .rev()\n                                .map(|v| format!(\"_/{v}\"))\n                                .collect::<Vec<String>>(),\n                        )\n                    } else {\n                        map_completion_values(self.list_sessions())\n                    }\n                }\n                \".rag\" => map_completion_values(Self::list_rags()),\n                \".agent\" => map_completion_values(list_agents()),\n                \".macro\" => map_completion_values(Self::list_macros()),\n                \".starter\" => match &self.agent {\n                    Some(agent) => agent\n                        .conversation_staters()\n                        .iter()\n                        .enumerate()\n                        .map(|(i, v)| ((i + 1).to_string(), Some(v.to_string())))\n                        .collect(),\n                    None => vec![],\n                },\n                \".set\" => {\n                    let mut values = vec![\n                        \"temperature\",\n                        \"top_p\",\n                        \"use_tools\",\n                        \"save_session\",\n                        \"compress_threshold\",\n                        \"rag_reranker_model\",\n                        \"rag_top_k\",\n                        \"max_output_tokens\",\n                        \"dry_run\",\n                        \"function_calling\",\n                        \"stream\",\n                        \"save\",\n                        \"highlight\",\n                    ];\n                    values.sort_unstable();\n                    values\n                        .into_iter()\n                        .map(|v| (format!(\"{v} \"), None))\n                        .collect()\n                }\n                \".delete\" => {\n                    map_completion_values(vec![\"role\", \"session\", \"rag\", \"macro\", \"agent-data\"])\n                }\n                _ => vec![],\n            };\n        } else if cmd == \".set\" && args.len() == 2 {\n            let candidates = match args[0] {\n                \"max_output_tokens\" => match self.current_model().max_output_tokens() {\n                    Some(v) => vec![v.to_string()],\n                    None => vec![],\n                },\n                \"dry_run\" => complete_bool(self.dry_run),\n                \"stream\" => complete_bool(self.stream),\n                \"save\" => complete_bool(self.save),\n                \"function_calling\" => complete_bool(self.function_calling),\n                \"use_tools\" => {\n                    let mut prefix = String::new();\n                    let mut ignores = HashSet::new();\n                    if let Some((v, _)) = args[1].rsplit_once(',') {\n                        ignores = v.split(',').collect();\n                        prefix = format!(\"{v},\");\n                    }\n                    let mut values = vec![];\n                    if prefix.is_empty() {\n                        values.push(\"all\".to_string());\n                    }\n                    values.extend(self.functions.declarations().iter().map(|v| v.name.clone()));\n                    values.extend(self.mapping_tools.keys().map(|v| v.to_string()));\n                    values\n                        .into_iter()\n                        .filter(|v| !ignores.contains(v.as_str()))\n                        .map(|v| format!(\"{prefix}{v}\"))\n                        .collect()\n                }\n                \"save_session\" => {\n                    let save_session = if let Some(session) = &self.session {\n                        session.save_session()\n                    } else {\n                        self.save_session\n                    };\n                    complete_option_bool(save_session)\n                }\n                \"rag_reranker_model\" => list_models(self, ModelType::Reranker)\n                    .iter()\n                    .map(|v| v.id())\n                    .collect(),\n                \"highlight\" => complete_bool(self.highlight),\n                _ => vec![],\n            };\n            values = candidates.into_iter().map(|v| (v, None)).collect();\n        } else if cmd == \".agent\" {\n            if args.len() == 2 {\n                let dir = Self::agent_data_dir(args[0]).join(SESSIONS_DIR_NAME);\n                values = list_file_names(dir, \".yaml\")\n                    .into_iter()\n                    .map(|v| (v, None))\n                    .collect();\n            }\n            values.extend(complete_agent_variables(args[0]));\n        };\n        fuzzy_filter(values, |v| v.0.as_str(), filter)\n    }\n\n    pub fn sync_models_url(&self) -> String {\n        self.sync_models_url\n            .clone()\n            .unwrap_or_else(|| SYNC_MODELS_URL.into())\n    }\n\n    pub async fn sync_models(url: &str, abort_signal: AbortSignal) -> Result<()> {\n        let content = abortable_run_with_spinner(fetch(url), \"Fetching models.yaml\", abort_signal)\n            .await\n            .with_context(|| format!(\"Failed to fetch '{url}'\"))?;\n        println!(\"✓ Fetched '{url}'\");\n        let list = serde_yaml::from_str::<Vec<ProviderModels>>(&content)\n            .with_context(|| \"Failed to parse models.yaml\")?;\n        let models_override = ModelsOverride {\n            version: env!(\"CARGO_PKG_VERSION\").to_string(),\n            list,\n        };\n        let models_override_data =\n            serde_yaml::to_string(&models_override).with_context(|| \"Failed to serde {}\")?;\n\n        let model_override_path = Self::models_override_file();\n        ensure_parent_exists(&model_override_path)?;\n        std::fs::write(&model_override_path, models_override_data)\n            .with_context(|| format!(\"Failed to write to '{}'\", model_override_path.display()))?;\n        println!(\"✓ Updated '{}'\", model_override_path.display());\n        Ok(())\n    }\n\n    pub fn loal_models_override() -> Result<Vec<ProviderModels>> {\n        let model_override_path = Self::models_override_file();\n        let err = || {\n            format!(\n                \"Failed to load models at '{}'\",\n                model_override_path.display()\n            )\n        };\n        let content = read_to_string(&model_override_path).with_context(err)?;\n        let models_override: ModelsOverride = serde_yaml::from_str(&content).with_context(err)?;\n        if models_override.version != env!(\"CARGO_PKG_VERSION\") {\n            bail!(\"Incompatible version\")\n        }\n        Ok(models_override.list)\n    }\n\n    pub fn light_theme(&self) -> bool {\n        matches!(self.theme.as_deref(), Some(\"light\"))\n    }\n\n    pub fn render_options(&self) -> Result<RenderOptions> {\n        let theme = if self.highlight {\n            let theme_mode = if self.light_theme() { \"light\" } else { \"dark\" };\n            let theme_filename = format!(\"{theme_mode}.tmTheme\");\n            let theme_path = Self::local_path(&theme_filename);\n            if theme_path.exists() {\n                let theme = ThemeSet::get_theme(&theme_path)\n                    .with_context(|| format!(\"Invalid theme at '{}'\", theme_path.display()))?;\n                Some(theme)\n            } else {\n                let theme = if self.light_theme() {\n                    decode_bin(LIGHT_THEME).context(\"Invalid builtin light theme\")?\n                } else {\n                    decode_bin(DARK_THEME).context(\"Invalid builtin dark theme\")?\n                };\n                Some(theme)\n            }\n        } else {\n            None\n        };\n        let wrap = if *IS_STDOUT_TERMINAL {\n            self.wrap.clone()\n        } else {\n            None\n        };\n        let truecolor = matches!(\n            env::var(\"COLORTERM\").as_ref().map(|v| v.as_str()),\n            Ok(\"truecolor\")\n        );\n        Ok(RenderOptions::new(theme, wrap, self.wrap_code, truecolor))\n    }\n\n    pub fn render_prompt_left(&self) -> String {\n        let variables = self.generate_prompt_context();\n        let left_prompt = self.left_prompt.as_deref().unwrap_or(LEFT_PROMPT);\n        render_prompt(left_prompt, &variables)\n    }\n\n    pub fn render_prompt_right(&self) -> String {\n        let variables = self.generate_prompt_context();\n        let right_prompt = self.right_prompt.as_deref().unwrap_or(RIGHT_PROMPT);\n        render_prompt(right_prompt, &variables)\n    }\n\n    pub fn print_markdown(&self, text: &str) -> Result<()> {\n        if *IS_STDOUT_TERMINAL {\n            let render_options = self.render_options()?;\n            let mut markdown_render = MarkdownRender::init(render_options)?;\n            println!(\"{}\", markdown_render.render(text));\n        } else {\n            println!(\"{text}\");\n        }\n        Ok(())\n    }\n\n    fn generate_prompt_context(&self) -> HashMap<&str, String> {\n        let mut output = HashMap::new();\n        let role = self.extract_role();\n        output.insert(\"model\", role.model().id());\n        output.insert(\"client_name\", role.model().client_name().to_string());\n        output.insert(\"model_name\", role.model().name().to_string());\n        output.insert(\n            \"max_input_tokens\",\n            role.model()\n                .max_input_tokens()\n                .unwrap_or_default()\n                .to_string(),\n        );\n        if let Some(temperature) = role.temperature() {\n            if temperature != 0.0 {\n                output.insert(\"temperature\", temperature.to_string());\n            }\n        }\n        if let Some(top_p) = role.top_p() {\n            if top_p != 0.0 {\n                output.insert(\"top_p\", top_p.to_string());\n            }\n        }\n        if self.dry_run {\n            output.insert(\"dry_run\", \"true\".to_string());\n        }\n        if self.stream {\n            output.insert(\"stream\", \"true\".to_string());\n        }\n        if self.save {\n            output.insert(\"save\", \"true\".to_string());\n        }\n        if let Some(wrap) = &self.wrap {\n            if wrap != \"no\" {\n                output.insert(\"wrap\", wrap.clone());\n            }\n        }\n        if !role.is_derived() {\n            output.insert(\"role\", role.name().to_string());\n        }\n        if let Some(session) = &self.session {\n            output.insert(\"session\", session.name().to_string());\n            if let Some(autoname) = session.autoname() {\n                output.insert(\"session_autoname\", autoname.to_string());\n            }\n            output.insert(\"dirty\", session.dirty().to_string());\n            let (tokens, percent) = session.tokens_usage();\n            output.insert(\"consume_tokens\", tokens.to_string());\n            output.insert(\"consume_percent\", percent.to_string());\n            output.insert(\"user_messages_len\", session.user_messages_len().to_string());\n        }\n        if let Some(rag) = &self.rag {\n            output.insert(\"rag\", rag.name().to_string());\n        }\n        if let Some(agent) = &self.agent {\n            output.insert(\"agent\", agent.name().to_string());\n        }\n\n        if self.highlight {\n            output.insert(\"color.reset\", \"\\u{1b}[0m\".to_string());\n            output.insert(\"color.black\", \"\\u{1b}[30m\".to_string());\n            output.insert(\"color.dark_gray\", \"\\u{1b}[90m\".to_string());\n            output.insert(\"color.red\", \"\\u{1b}[31m\".to_string());\n            output.insert(\"color.light_red\", \"\\u{1b}[91m\".to_string());\n            output.insert(\"color.green\", \"\\u{1b}[32m\".to_string());\n            output.insert(\"color.light_green\", \"\\u{1b}[92m\".to_string());\n            output.insert(\"color.yellow\", \"\\u{1b}[33m\".to_string());\n            output.insert(\"color.light_yellow\", \"\\u{1b}[93m\".to_string());\n            output.insert(\"color.blue\", \"\\u{1b}[34m\".to_string());\n            output.insert(\"color.light_blue\", \"\\u{1b}[94m\".to_string());\n            output.insert(\"color.purple\", \"\\u{1b}[35m\".to_string());\n            output.insert(\"color.light_purple\", \"\\u{1b}[95m\".to_string());\n            output.insert(\"color.magenta\", \"\\u{1b}[35m\".to_string());\n            output.insert(\"color.light_magenta\", \"\\u{1b}[95m\".to_string());\n            output.insert(\"color.cyan\", \"\\u{1b}[36m\".to_string());\n            output.insert(\"color.light_cyan\", \"\\u{1b}[96m\".to_string());\n            output.insert(\"color.white\", \"\\u{1b}[37m\".to_string());\n            output.insert(\"color.light_gray\", \"\\u{1b}[97m\".to_string());\n        }\n\n        output\n    }\n\n    pub fn before_chat_completion(&mut self, input: &Input) -> Result<()> {\n        self.last_message = Some(LastMessage::new(input.clone(), String::new()));\n        Ok(())\n    }\n\n    pub fn after_chat_completion(\n        &mut self,\n        input: &Input,\n        output: &str,\n        tool_results: &[ToolResult],\n    ) -> Result<()> {\n        if !tool_results.is_empty() {\n            return Ok(());\n        }\n        self.last_message = Some(LastMessage::new(input.clone(), output.to_string()));\n        if !self.dry_run {\n            self.save_message(input, output)?;\n        }\n        Ok(())\n    }\n\n    fn discontinuous_last_message(&mut self) {\n        if let Some(last_message) = self.last_message.as_mut() {\n            last_message.continuous = false;\n        }\n    }\n\n    fn save_message(&mut self, input: &Input, output: &str) -> Result<()> {\n        let mut input = input.clone();\n        input.clear_patch();\n        if let Some(session) = input.session_mut(&mut self.session) {\n            session.add_message(&input, output)?;\n            return Ok(());\n        }\n\n        if !self.save {\n            return Ok(());\n        }\n        let mut file = self.open_message_file()?;\n        if output.is_empty() && input.tool_calls().is_none() {\n            return Ok(());\n        }\n        let now = now();\n        let summary = input.summary();\n        let raw_input = input.raw();\n        let scope = if self.agent.is_none() {\n            let role_name = if input.role().is_derived() {\n                None\n            } else {\n                Some(input.role().name())\n            };\n            match (role_name, input.rag_name()) {\n                (Some(role), Some(rag_name)) => format!(\" ({role}#{rag_name})\"),\n                (Some(role), _) => format!(\" ({role})\"),\n                (None, Some(rag_name)) => format!(\" (#{rag_name})\"),\n                _ => String::new(),\n            }\n        } else {\n            String::new()\n        };\n        let tool_calls = match input.tool_calls() {\n            Some(MessageContentToolCalls {\n                tool_results, text, ..\n            }) => {\n                let mut lines = vec![\"<tool_calls>\".to_string()];\n                if !text.is_empty() {\n                    lines.push(text.clone());\n                }\n                lines.push(serde_json::to_string(&tool_results).unwrap_or_default());\n                lines.push(\"</tool_calls>\\n\".to_string());\n                lines.join(\"\\n\")\n            }\n            None => String::new(),\n        };\n        let output = format!(\n            \"# CHAT: {summary} [{now}]{scope}\\n{raw_input}\\n--------\\n{tool_calls}{output}\\n--------\\n\\n\",\n        );\n        file.write_all(output.as_bytes())\n            .with_context(|| \"Failed to save message\")\n    }\n\n    fn init_agent_shared_variables(&mut self) -> Result<()> {\n        let agent = match self.agent.as_mut() {\n            Some(v) => v,\n            None => return Ok(()),\n        };\n        if !agent.defined_variables().is_empty() && agent.shared_variables().is_empty() {\n            let mut config_variables = agent.config_variables().clone();\n            if let Some(v) = &self.agent_variables {\n                config_variables.extend(v.clone());\n            }\n            let new_variables = Agent::init_agent_variables(\n                agent.defined_variables(),\n                &config_variables,\n                self.info_flag,\n            )?;\n            agent.set_shared_variables(new_variables);\n        }\n        if !self.info_flag {\n            agent.update_shared_dynamic_instructions(false)?;\n        }\n        Ok(())\n    }\n\n    fn init_agent_session_variables(&mut self, new_session: bool) -> Result<()> {\n        let (agent, session) = match (self.agent.as_mut(), self.session.as_mut()) {\n            (Some(agent), Some(session)) => (agent, session),\n            _ => return Ok(()),\n        };\n        if new_session {\n            let shared_variables = agent.shared_variables().clone();\n            let session_variables =\n                if !agent.defined_variables().is_empty() && shared_variables.is_empty() {\n                    let mut config_variables = agent.config_variables().clone();\n                    if let Some(v) = &self.agent_variables {\n                        config_variables.extend(v.clone());\n                    }\n                    let new_variables = Agent::init_agent_variables(\n                        agent.defined_variables(),\n                        &config_variables,\n                        self.info_flag,\n                    )?;\n                    agent.set_shared_variables(new_variables.clone());\n                    new_variables\n                } else {\n                    shared_variables\n                };\n            agent.set_session_variables(session_variables);\n            if !self.info_flag {\n                agent.update_session_dynamic_instructions(None)?;\n            }\n            session.sync_agent(agent);\n        } else {\n            let variables = session.agent_variables();\n            agent.set_session_variables(variables.clone());\n            agent.update_session_dynamic_instructions(Some(\n                session.agent_instructions().to_string(),\n            ))?;\n        }\n        Ok(())\n    }\n\n    fn open_message_file(&self) -> Result<File> {\n        let path = self.messages_file();\n        ensure_parent_exists(&path)?;\n        OpenOptions::new()\n            .create(true)\n            .append(true)\n            .open(&path)\n            .with_context(|| format!(\"Failed to create/append {}\", path.display()))\n    }\n\n    fn load_from_file(config_path: &Path) -> Result<Self> {\n        let err = || format!(\"Failed to load config at '{}'\", config_path.display());\n        let content = read_to_string(config_path).with_context(err)?;\n        let config: Self = serde_yaml::from_str(&content)\n            .map_err(|err| {\n                let err_msg = err.to_string();\n                let err_msg = if err_msg.starts_with(&format!(\"{CLIENTS_FIELD}: \")) {\n                    // location is incorrect, get rid of it\n                    err_msg\n                        .split_once(\" at line\")\n                        .map(|(v, _)| {\n                            format!(\"{v} (Sorry for being unable to provide an exact location)\")\n                        })\n                        .unwrap_or_else(|| \"clients: invalid value\".into())\n                } else {\n                    err_msg\n                };\n                anyhow!(\"{err_msg}\")\n            })\n            .with_context(err)?;\n\n        Ok(config)\n    }\n\n    fn load_dynamic(model_id: &str) -> Result<Self> {\n        let provider = match model_id.split_once(':') {\n            Some((v, _)) => v,\n            _ => model_id,\n        };\n        let is_openai_compatible = OPENAI_COMPATIBLE_PROVIDERS\n            .into_iter()\n            .any(|(name, _)| provider == name);\n        let client = if is_openai_compatible {\n            json!({ \"type\": \"openai-compatible\", \"name\": provider })\n        } else {\n            json!({ \"type\": provider })\n        };\n        let config = json!({\n            \"model\": model_id.to_string(),\n            \"save\": false,\n            \"clients\": vec![client],\n        });\n        let config =\n            serde_json::from_value(config).with_context(|| \"Failed to load config from env\")?;\n        Ok(config)\n    }\n\n    fn load_envs(&mut self) {\n        if let Ok(v) = env::var(get_env_name(\"model\")) {\n            self.model_id = v;\n        }\n        if let Some(v) = read_env_value::<f64>(&get_env_name(\"temperature\")) {\n            self.temperature = v;\n        }\n        if let Some(v) = read_env_value::<f64>(&get_env_name(\"top_p\")) {\n            self.top_p = v;\n        }\n\n        if let Some(Some(v)) = read_env_bool(&get_env_name(\"dry_run\")) {\n            self.dry_run = v;\n        }\n        if let Some(Some(v)) = read_env_bool(&get_env_name(\"stream\")) {\n            self.stream = v;\n        }\n        if let Some(Some(v)) = read_env_bool(&get_env_name(\"save\")) {\n            self.save = v;\n        }\n        if let Ok(v) = env::var(get_env_name(\"keybindings\")) {\n            if v == \"vi\" {\n                self.keybindings = v;\n            }\n        }\n        if let Some(v) = read_env_value::<String>(&get_env_name(\"editor\")) {\n            self.editor = v;\n        }\n        if let Some(v) = read_env_value::<String>(&get_env_name(\"wrap\")) {\n            self.wrap = v;\n        }\n        if let Some(Some(v)) = read_env_bool(&get_env_name(\"wrap_code\")) {\n            self.wrap_code = v;\n        }\n\n        if let Some(Some(v)) = read_env_bool(&get_env_name(\"function_calling\")) {\n            self.function_calling = v;\n        }\n        if let Ok(v) = env::var(get_env_name(\"mapping_tools\")) {\n            if let Ok(v) = serde_json::from_str(&v) {\n                self.mapping_tools = v;\n            }\n        }\n        if let Some(v) = read_env_value::<String>(&get_env_name(\"use_tools\")) {\n            self.use_tools = v;\n        }\n\n        if let Some(v) = read_env_value::<String>(&get_env_name(\"repl_prelude\")) {\n            self.repl_prelude = v;\n        }\n        if let Some(v) = read_env_value::<String>(&get_env_name(\"cmd_prelude\")) {\n            self.cmd_prelude = v;\n        }\n        if let Some(v) = read_env_value::<String>(&get_env_name(\"agent_prelude\")) {\n            self.agent_prelude = v;\n        }\n\n        if let Some(v) = read_env_bool(&get_env_name(\"save_session\")) {\n            self.save_session = v;\n        }\n        if let Some(Some(v)) = read_env_value::<usize>(&get_env_name(\"compress_threshold\")) {\n            self.compress_threshold = v;\n        }\n        if let Some(v) = read_env_value::<String>(&get_env_name(\"summarize_prompt\")) {\n            self.summarize_prompt = v;\n        }\n        if let Some(v) = read_env_value::<String>(&get_env_name(\"summary_prompt\")) {\n            self.summary_prompt = v;\n        }\n\n        if let Some(v) = read_env_value::<String>(&get_env_name(\"rag_embedding_model\")) {\n            self.rag_embedding_model = v;\n        }\n        if let Some(v) = read_env_value::<String>(&get_env_name(\"rag_reranker_model\")) {\n            self.rag_reranker_model = v;\n        }\n        if let Some(Some(v)) = read_env_value::<usize>(&get_env_name(\"rag_top_k\")) {\n            self.rag_top_k = v;\n        }\n        if let Some(v) = read_env_value::<usize>(&get_env_name(\"rag_chunk_size\")) {\n            self.rag_chunk_size = v;\n        }\n        if let Some(v) = read_env_value::<usize>(&get_env_name(\"rag_chunk_overlap\")) {\n            self.rag_chunk_overlap = v;\n        }\n        if let Some(v) = read_env_value::<String>(&get_env_name(\"rag_template\")) {\n            self.rag_template = v;\n        }\n\n        if let Ok(v) = env::var(get_env_name(\"document_loaders\")) {\n            if let Ok(v) = serde_json::from_str(&v) {\n                self.document_loaders = v;\n            }\n        }\n\n        if let Some(Some(v)) = read_env_bool(&get_env_name(\"highlight\")) {\n            self.highlight = v;\n        }\n        if *NO_COLOR {\n            self.highlight = false;\n        }\n        if self.highlight && self.theme.is_none() {\n            if let Some(v) = read_env_value::<String>(&get_env_name(\"theme\")) {\n                self.theme = v;\n            } else if *IS_STDOUT_TERMINAL {\n                if let Ok(color_scheme) = color_scheme(QueryOptions::default()) {\n                    let theme = match color_scheme {\n                        ColorScheme::Dark => \"dark\",\n                        ColorScheme::Light => \"light\",\n                    };\n                    self.theme = Some(theme.into());\n                }\n            }\n        }\n        if let Some(v) = read_env_value::<String>(&get_env_name(\"left_prompt\")) {\n            self.left_prompt = v;\n        }\n        if let Some(v) = read_env_value::<String>(&get_env_name(\"right_prompt\")) {\n            self.right_prompt = v;\n        }\n\n        if let Some(v) = read_env_value::<String>(&get_env_name(\"serve_addr\")) {\n            self.serve_addr = v;\n        }\n        if let Some(v) = read_env_value::<String>(&get_env_name(\"user_agent\")) {\n            self.user_agent = v;\n        }\n        if let Some(Some(v)) = read_env_bool(&get_env_name(\"save_shell_history\")) {\n            self.save_shell_history = v;\n        }\n        if let Some(v) = read_env_value::<String>(&get_env_name(\"sync_models_url\")) {\n            self.sync_models_url = v;\n        }\n    }\n\n    fn load_functions(&mut self) -> Result<()> {\n        self.functions = Functions::init(&Self::functions_file())?;\n        Ok(())\n    }\n\n    fn setup_model(&mut self) -> Result<()> {\n        let mut model_id = self.model_id.clone();\n        if model_id.is_empty() {\n            let models = list_models(self, ModelType::Chat);\n            if models.is_empty() {\n                bail!(\"No available model\");\n            }\n            model_id = models[0].id()\n        };\n        self.set_model(&model_id)?;\n        self.model_id = model_id;\n        Ok(())\n    }\n\n    fn setup_document_loaders(&mut self) {\n        [(\"pdf\", \"pdftotext $1 -\"), (\"docx\", \"pandoc --to plain $1\")]\n            .into_iter()\n            .for_each(|(k, v)| {\n                let (k, v) = (k.to_string(), v.to_string());\n                self.document_loaders.entry(k).or_insert(v);\n            });\n    }\n\n    fn setup_user_agent(&mut self) {\n        if let Some(\"auto\") = self.user_agent.as_deref() {\n            self.user_agent = Some(format!(\n                \"{}/{}\",\n                env!(\"CARGO_CRATE_NAME\"),\n                env!(\"CARGO_PKG_VERSION\")\n            ));\n        }\n    }\n}\n\npub fn load_env_file() -> Result<()> {\n    let env_file_path = Config::env_file();\n    let contents = match read_to_string(&env_file_path) {\n        Ok(v) => v,\n        Err(_) => return Ok(()),\n    };\n    debug!(\"Use env file '{}'\", env_file_path.display());\n    for line in contents.lines() {\n        let line = line.trim();\n        if line.starts_with('#') || line.is_empty() {\n            continue;\n        }\n        if let Some((key, value)) = line.split_once('=') {\n            env::set_var(key.trim(), value.trim());\n        }\n    }\n    Ok(())\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]\npub enum WorkingMode {\n    Cmd,\n    Repl,\n    Serve,\n}\n\nimpl WorkingMode {\n    pub fn is_cmd(&self) -> bool {\n        *self == WorkingMode::Cmd\n    }\n    pub fn is_repl(&self) -> bool {\n        *self == WorkingMode::Repl\n    }\n    pub fn is_serve(&self) -> bool {\n        *self == WorkingMode::Serve\n    }\n}\n\n#[async_recursion::async_recursion]\npub async fn macro_execute(\n    config: &GlobalConfig,\n    name: &str,\n    args: Option<&str>,\n    abort_signal: AbortSignal,\n) -> Result<()> {\n    let macro_value = Config::load_macro(name)?;\n    let (mut new_args, text) = split_args_text(args.unwrap_or_default(), cfg!(windows));\n    if !text.is_empty() {\n        new_args.push(text.to_string());\n    }\n    let variables = macro_value\n        .resolve_variables(&new_args)\n        .map_err(|err| anyhow!(\"{err}. Usage: {}\", macro_value.usage(name)))?;\n    let role = config.read().extract_role();\n    let mut config = config.read().clone();\n    config.temperature = role.temperature();\n    config.top_p = role.top_p();\n    config.use_tools = role.use_tools().clone();\n    config.macro_flag = true;\n    config.model = role.model().clone();\n    config.role = None;\n    config.session = None;\n    config.rag = None;\n    config.agent = None;\n    config.discontinuous_last_message();\n    let config = Arc::new(RwLock::new(config));\n    config.write().macro_flag = true;\n    for step in &macro_value.steps {\n        let command = Macro::interpolate_command(step, &variables);\n        println!(\">> {}\", multiline_text(&command));\n        run_repl_command(&config, abort_signal.clone(), &command).await?;\n    }\n    Ok(())\n}\n\n#[derive(Debug, Clone, Deserialize)]\npub struct Macro {\n    #[serde(default)]\n    pub variables: Vec<MacroVariable>,\n    pub steps: Vec<String>,\n}\n\nimpl Macro {\n    pub fn resolve_variables(&self, args: &[String]) -> Result<IndexMap<String, String>> {\n        let mut output = IndexMap::new();\n        for (i, variable) in self.variables.iter().enumerate() {\n            let value = if variable.rest && i == self.variables.len() - 1 {\n                if args.len() > i {\n                    Some(args[i..].join(\" \"))\n                } else {\n                    variable.default.clone()\n                }\n            } else {\n                args.get(i)\n                    .map(|v| v.to_string())\n                    .or_else(|| variable.default.clone())\n            };\n            let value =\n                value.ok_or_else(|| anyhow!(\"Missing value for variable '{}'\", variable.name))?;\n            output.insert(variable.name.clone(), value);\n        }\n        Ok(output)\n    }\n\n    pub fn usage(&self, name: &str) -> String {\n        let mut parts = vec![name.to_string()];\n        for (i, variable) in self.variables.iter().enumerate() {\n            let part = match (\n                variable.rest && i == self.variables.len() - 1,\n                variable.default.is_some(),\n            ) {\n                (true, true) => format!(\"[{}]...\", variable.name),\n                (true, false) => format!(\"<{}>...\", variable.name),\n                (false, true) => format!(\"[{}]\", variable.name),\n                (false, false) => format!(\"<{}>\", variable.name),\n            };\n            parts.push(part);\n        }\n        parts.join(\" \")\n    }\n\n    pub fn interpolate_command(command: &str, variables: &IndexMap<String, String>) -> String {\n        let mut output = command.to_string();\n        for (key, value) in variables {\n            output = output.replace(&format!(\"{{{{{key}}}}}\"), value);\n        }\n        output\n    }\n}\n\n#[derive(Debug, Clone, Deserialize)]\npub struct MacroVariable {\n    pub name: String,\n    #[serde(default)]\n    pub rest: bool,\n    pub default: Option<String>,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct ModelsOverride {\n    pub version: String,\n    pub list: Vec<ProviderModels>,\n}\n\n#[derive(Debug, Clone)]\npub struct LastMessage {\n    pub input: Input,\n    pub output: String,\n    pub continuous: bool,\n}\n\nimpl LastMessage {\n    pub fn new(input: Input, output: String) -> Self {\n        Self {\n            input,\n            output,\n            continuous: true,\n        }\n    }\n}\n\nbitflags::bitflags! {\n    #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]\n    pub struct StateFlags: u32 {\n        const ROLE = 1 << 0;\n        const SESSION_EMPTY = 1 << 1;\n        const SESSION = 1 << 2;\n        const RAG = 1 << 3;\n        const AGENT = 1 << 4;\n    }\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]\npub enum AssertState {\n    True(StateFlags),\n    False(StateFlags),\n    TrueFalse(StateFlags, StateFlags),\n    Equal(StateFlags),\n}\n\nimpl AssertState {\n    pub fn pass() -> Self {\n        AssertState::False(StateFlags::empty())\n    }\n\n    pub fn bare() -> Self {\n        AssertState::Equal(StateFlags::empty())\n    }\n\n    pub fn assert(self, flags: StateFlags) -> bool {\n        match self {\n            AssertState::True(true_flags) => true_flags & flags != StateFlags::empty(),\n            AssertState::False(false_flags) => false_flags & flags == StateFlags::empty(),\n            AssertState::TrueFalse(true_flags, false_flags) => {\n                (true_flags & flags != StateFlags::empty())\n                    && (false_flags & flags == StateFlags::empty())\n            }\n            AssertState::Equal(check_flags) => check_flags == flags,\n        }\n    }\n}\n\nasync fn create_config_file(config_path: &Path) -> Result<()> {\n    let ans = Confirm::new(\"No config file, create a new one?\")\n        .with_default(true)\n        .prompt()?;\n    if !ans {\n        process::exit(0);\n    }\n\n    let client = Select::new(\"API Provider (required):\", list_client_types()).prompt()?;\n\n    let mut config = serde_json::json!({});\n    let (model, clients_config) = create_client_config(client).await?;\n    config[\"model\"] = model.into();\n    config[CLIENTS_FIELD] = clients_config;\n\n    let config_data = serde_yaml::to_string(&config).with_context(|| \"Failed to create config\")?;\n    let config_data = format!(\n        \"# see https://github.com/sigoden/aichat/blob/main/config.example.yaml\\n\\n{config_data}\"\n    );\n\n    ensure_parent_exists(config_path)?;\n    std::fs::write(config_path, config_data)\n        .with_context(|| format!(\"Failed to write to '{}'\", config_path.display()))?;\n    #[cfg(unix)]\n    {\n        use std::os::unix::prelude::PermissionsExt;\n        let perms = std::fs::Permissions::from_mode(0o600);\n        std::fs::set_permissions(config_path, perms)?;\n    }\n\n    println!(\"✓ Saved the config file to '{}'.\\n\", config_path.display());\n\n    Ok(())\n}\n\npub(crate) fn ensure_parent_exists(path: &Path) -> Result<()> {\n    if path.exists() {\n        return Ok(());\n    }\n    let parent = path\n        .parent()\n        .ok_or_else(|| anyhow!(\"Failed to write to '{}', No parent path\", path.display()))?;\n    if !parent.exists() {\n        create_dir_all(parent).with_context(|| {\n            format!(\n                \"Failed to write to '{}', Cannot create parent directory\",\n                path.display()\n            )\n        })?;\n    }\n    Ok(())\n}\n\nfn read_env_value<T>(key: &str) -> Option<Option<T>>\nwhere\n    T: std::str::FromStr,\n{\n    let value = env::var(key).ok()?;\n    let value = parse_value(&value).ok()?;\n    Some(value)\n}\n\nfn parse_value<T>(value: &str) -> Result<Option<T>>\nwhere\n    T: std::str::FromStr,\n{\n    let value = if value == \"null\" {\n        None\n    } else {\n        let value = match value.parse() {\n            Ok(value) => value,\n            Err(_) => bail!(\"Invalid value '{}'\", value),\n        };\n        Some(value)\n    };\n    Ok(value)\n}\n\nfn read_env_bool(key: &str) -> Option<Option<bool>> {\n    let value = env::var(key).ok()?;\n    Some(parse_bool(&value))\n}\n\nfn complete_bool(value: bool) -> Vec<String> {\n    vec![(!value).to_string()]\n}\n\nfn complete_option_bool(value: Option<bool>) -> Vec<String> {\n    match value {\n        Some(true) => vec![\"false\".to_string(), \"null\".to_string()],\n        Some(false) => vec![\"true\".to_string(), \"null\".to_string()],\n        None => vec![\"true\".to_string(), \"false\".to_string()],\n    }\n}\n\nfn map_completion_values<T: ToString>(value: Vec<T>) -> Vec<(String, Option<String>)> {\n    value.into_iter().map(|v| (v.to_string(), None)).collect()\n}\n\nfn update_rag<F>(config: &GlobalConfig, f: F) -> Result<()>\nwhere\n    F: FnOnce(&mut Rag) -> Result<()>,\n{\n    let mut rag = match config.read().rag.clone() {\n        Some(v) => v.as_ref().clone(),\n        None => bail!(\"No RAG\"),\n    };\n    f(&mut rag)?;\n    config.write().rag = Some(Arc::new(rag));\n    Ok(())\n}\n\nfn format_option_value<T>(value: &Option<T>) -> String\nwhere\n    T: std::fmt::Display,\n{\n    match value {\n        Some(value) => value.to_string(),\n        None => \"null\".to_string(),\n    }\n}\n"
  },
  {
    "path": "src/config/role.rs",
    "content": "use super::*;\n\nuse crate::client::{Message, MessageContent, MessageRole, Model};\n\nuse anyhow::Result;\nuse fancy_regex::Regex;\nuse rust_embed::Embed;\nuse serde::{Deserialize, Serialize};\nuse serde_json::Value;\nuse std::sync::LazyLock;\n\npub const SHELL_ROLE: &str = \"%shell%\";\npub const EXPLAIN_SHELL_ROLE: &str = \"%explain-shell%\";\npub const CODE_ROLE: &str = \"%code%\";\npub const CREATE_TITLE_ROLE: &str = \"%create-title%\";\n\npub const INPUT_PLACEHOLDER: &str = \"__INPUT__\";\n\n#[derive(Embed)]\n#[folder = \"assets/roles/\"]\nstruct RolesAsset;\n\nstatic RE_METADATA: LazyLock<Regex> =\n    LazyLock::new(|| Regex::new(r\"(?s)-{3,}\\s*(.*?)\\s*-{3,}\\s*(.*)\").unwrap());\n\npub trait RoleLike {\n    fn to_role(&self) -> Role;\n    fn model(&self) -> &Model;\n    fn temperature(&self) -> Option<f64>;\n    fn top_p(&self) -> Option<f64>;\n    fn use_tools(&self) -> Option<String>;\n    fn set_model(&mut self, model: Model);\n    fn set_temperature(&mut self, value: Option<f64>);\n    fn set_top_p(&mut self, value: Option<f64>);\n    fn set_use_tools(&mut self, value: Option<String>);\n}\n\n#[derive(Debug, Clone, Default, Deserialize, Serialize)]\npub struct Role {\n    name: String,\n    #[serde(default)]\n    prompt: String,\n    #[serde(\n        rename(serialize = \"model\", deserialize = \"model\"),\n        skip_serializing_if = \"Option::is_none\"\n    )]\n    model_id: Option<String>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    temperature: Option<f64>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    top_p: Option<f64>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    use_tools: Option<String>,\n\n    #[serde(skip)]\n    model: Model,\n}\n\nimpl Role {\n    pub fn new(name: &str, content: &str) -> Self {\n        let mut metadata = \"\";\n        let mut prompt = content.trim();\n        if let Ok(Some(caps)) = RE_METADATA.captures(content) {\n            if let (Some(metadata_value), Some(prompt_value)) = (caps.get(1), caps.get(2)) {\n                metadata = metadata_value.as_str().trim();\n                prompt = prompt_value.as_str().trim();\n            }\n        }\n        let mut prompt = prompt.to_string();\n        interpolate_variables(&mut prompt);\n        let mut role = Self {\n            name: name.to_string(),\n            prompt,\n            ..Default::default()\n        };\n        if !metadata.is_empty() {\n            if let Ok(value) = serde_yaml::from_str::<Value>(metadata) {\n                if let Some(value) = value.as_object() {\n                    for (key, value) in value {\n                        match key.as_str() {\n                            \"model\" => role.model_id = value.as_str().map(|v| v.to_string()),\n                            \"temperature\" => role.temperature = value.as_f64(),\n                            \"top_p\" => role.top_p = value.as_f64(),\n                            \"use_tools\" => role.use_tools = value.as_str().map(|v| v.to_string()),\n                            _ => (),\n                        }\n                    }\n                }\n            }\n        }\n        role\n    }\n\n    pub fn builtin(name: &str) -> Result<Self> {\n        let content = RolesAsset::get(&format!(\"{name}.md\"))\n            .ok_or_else(|| anyhow!(\"Unknown role `{name}`\"))?;\n        let content = unsafe { std::str::from_utf8_unchecked(&content.data) };\n        Ok(Role::new(name, content))\n    }\n\n    pub fn list_builtin_role_names() -> Vec<String> {\n        RolesAsset::iter()\n            .filter_map(|v| v.strip_suffix(\".md\").map(|v| v.to_string()))\n            .collect()\n    }\n\n    pub fn list_builtin_roles() -> Vec<Self> {\n        RolesAsset::iter()\n            .filter_map(|v| Role::builtin(&v).ok())\n            .collect()\n    }\n\n    pub fn has_args(&self) -> bool {\n        self.name.contains('#')\n    }\n\n    pub fn export(&self) -> String {\n        let mut metadata = vec![];\n        if let Some(model) = self.model_id() {\n            metadata.push(format!(\"model: {model}\"));\n        }\n        if let Some(temperature) = self.temperature() {\n            metadata.push(format!(\"temperature: {temperature}\"));\n        }\n        if let Some(top_p) = self.top_p() {\n            metadata.push(format!(\"top_p: {top_p}\"));\n        }\n        if let Some(use_tools) = self.use_tools() {\n            metadata.push(format!(\"use_tools: {use_tools}\"));\n        }\n        if metadata.is_empty() {\n            format!(\"{}\\n\", self.prompt)\n        } else if self.prompt.is_empty() {\n            format!(\"---\\n{}\\n---\\n\", metadata.join(\"\\n\"))\n        } else {\n            format!(\"---\\n{}\\n---\\n\\n{}\\n\", metadata.join(\"\\n\"), self.prompt)\n        }\n    }\n\n    pub fn save(&mut self, role_name: &str, role_path: &Path, is_repl: bool) -> Result<()> {\n        ensure_parent_exists(role_path)?;\n\n        let content = self.export();\n        std::fs::write(role_path, content).with_context(|| {\n            format!(\n                \"Failed to write role {} to {}\",\n                self.name,\n                role_path.display()\n            )\n        })?;\n\n        if is_repl {\n            println!(\"✓ Saved role to '{}'.\", role_path.display());\n        }\n\n        if role_name != self.name {\n            self.name = role_name.to_string();\n        }\n\n        Ok(())\n    }\n\n    pub fn sync<T: RoleLike>(&mut self, role_like: &T) {\n        let model = role_like.model();\n        let temperature = role_like.temperature();\n        let top_p = role_like.top_p();\n        let use_tools = role_like.use_tools();\n        self.batch_set(model, temperature, top_p, use_tools);\n    }\n\n    pub fn batch_set(\n        &mut self,\n        model: &Model,\n        temperature: Option<f64>,\n        top_p: Option<f64>,\n        use_tools: Option<String>,\n    ) {\n        self.set_model(model.clone());\n        if temperature.is_some() {\n            self.set_temperature(temperature);\n        }\n        if top_p.is_some() {\n            self.set_top_p(top_p);\n        }\n        if use_tools.is_some() {\n            self.set_use_tools(use_tools);\n        }\n    }\n\n    pub fn is_derived(&self) -> bool {\n        self.name.is_empty()\n    }\n\n    pub fn name(&self) -> &str {\n        &self.name\n    }\n\n    pub fn model_id(&self) -> Option<&str> {\n        self.model_id.as_deref()\n    }\n\n    pub fn prompt(&self) -> &str {\n        &self.prompt\n    }\n\n    pub fn is_empty_prompt(&self) -> bool {\n        self.prompt.is_empty()\n    }\n\n    pub fn is_embedded_prompt(&self) -> bool {\n        self.prompt.contains(INPUT_PLACEHOLDER)\n    }\n\n    pub fn echo_messages(&self, input: &Input) -> String {\n        let input_markdown = input.render();\n        if self.is_empty_prompt() {\n            input_markdown\n        } else if self.is_embedded_prompt() {\n            self.prompt.replace(INPUT_PLACEHOLDER, &input_markdown)\n        } else {\n            format!(\"{}\\n\\n{}\", self.prompt, input_markdown)\n        }\n    }\n\n    pub fn build_messages(&self, input: &Input) -> Vec<Message> {\n        let mut content = input.message_content();\n        let mut messages = if self.is_empty_prompt() {\n            vec![Message::new(MessageRole::User, content)]\n        } else if self.is_embedded_prompt() {\n            content.merge_prompt(|v: &str| self.prompt.replace(INPUT_PLACEHOLDER, v));\n            vec![Message::new(MessageRole::User, content)]\n        } else {\n            let mut messages = vec![];\n            let (system, cases) = parse_structure_prompt(&self.prompt);\n            if !system.is_empty() {\n                messages.push(Message::new(\n                    MessageRole::System,\n                    MessageContent::Text(system.to_string()),\n                ));\n            }\n            if !cases.is_empty() {\n                messages.extend(cases.into_iter().flat_map(|(i, o)| {\n                    vec![\n                        Message::new(MessageRole::User, MessageContent::Text(i.to_string())),\n                        Message::new(MessageRole::Assistant, MessageContent::Text(o.to_string())),\n                    ]\n                }));\n            }\n            messages.push(Message::new(MessageRole::User, content));\n            messages\n        };\n        if let Some(text) = input.continue_output() {\n            messages.push(Message::new(\n                MessageRole::Assistant,\n                MessageContent::Text(text.into()),\n            ));\n        }\n        messages\n    }\n}\n\nimpl RoleLike for Role {\n    fn to_role(&self) -> Role {\n        self.clone()\n    }\n\n    fn model(&self) -> &Model {\n        &self.model\n    }\n\n    fn temperature(&self) -> Option<f64> {\n        self.temperature\n    }\n\n    fn top_p(&self) -> Option<f64> {\n        self.top_p\n    }\n\n    fn use_tools(&self) -> Option<String> {\n        self.use_tools.clone()\n    }\n\n    fn set_model(&mut self, model: Model) {\n        if !self.model().id().is_empty() {\n            self.model_id = Some(model.id().to_string());\n        }\n        self.model = model;\n    }\n\n    fn set_temperature(&mut self, value: Option<f64>) {\n        self.temperature = value;\n    }\n\n    fn set_top_p(&mut self, value: Option<f64>) {\n        self.top_p = value;\n    }\n\n    fn set_use_tools(&mut self, value: Option<String>) {\n        self.use_tools = value;\n    }\n}\n\nfn parse_structure_prompt(prompt: &str) -> (&str, Vec<(&str, &str)>) {\n    let mut text = prompt;\n    let mut search_input = true;\n    let mut system = None;\n    let mut parts = vec![];\n    loop {\n        let search = if search_input {\n            \"### INPUT:\"\n        } else {\n            \"### OUTPUT:\"\n        };\n        match text.find(search) {\n            Some(idx) => {\n                if system.is_none() {\n                    system = Some(&text[..idx])\n                } else {\n                    parts.push(&text[..idx])\n                }\n                search_input = !search_input;\n                text = &text[(idx + search.len())..];\n            }\n            None => {\n                if !text.is_empty() {\n                    if system.is_none() {\n                        system = Some(text)\n                    } else {\n                        parts.push(text)\n                    }\n                }\n                break;\n            }\n        }\n    }\n    let parts_len = parts.len();\n    if parts_len > 0 && parts_len % 2 == 0 {\n        let cases: Vec<(&str, &str)> = parts\n            .iter()\n            .step_by(2)\n            .zip(parts.iter().skip(1).step_by(2))\n            .map(|(i, o)| (i.trim(), o.trim()))\n            .collect();\n        let system = system.map(|v| v.trim()).unwrap_or_default();\n        return (system, cases);\n    }\n\n    (prompt, vec![])\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_parse_structure_prompt1() {\n        let prompt = r#\"\nSystem message\n### INPUT:\nInput 1\n### OUTPUT:\nOutput 1\n\"#;\n        assert_eq!(\n            parse_structure_prompt(prompt),\n            (\"System message\", vec![(\"Input 1\", \"Output 1\")])\n        );\n    }\n\n    #[test]\n    fn test_parse_structure_prompt2() {\n        let prompt = r#\"\n### INPUT:\nInput 1\n### OUTPUT:\nOutput 1\n\"#;\n        assert_eq!(\n            parse_structure_prompt(prompt),\n            (\"\", vec![(\"Input 1\", \"Output 1\")])\n        );\n    }\n\n    #[test]\n    fn test_parse_structure_prompt3() {\n        let prompt = r#\"\nSystem message\n### INPUT:\nInput 1\n\"#;\n        assert_eq!(parse_structure_prompt(prompt), (prompt, vec![]));\n    }\n}\n"
  },
  {
    "path": "src/config/session.rs",
    "content": "use super::input::*;\nuse super::*;\n\nuse crate::client::{Message, MessageContent, MessageRole};\nuse crate::render::MarkdownRender;\n\nuse anyhow::{bail, Context, Result};\nuse fancy_regex::Regex;\nuse inquire::{validator::Validation, Confirm, Text};\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\nuse std::collections::HashMap;\nuse std::fs::{read_to_string, write};\nuse std::path::Path;\nuse std::sync::LazyLock;\n\nstatic RE_AUTONAME_PREFIX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r\"\\d{8}T\\d{6}-\").unwrap());\n\n#[derive(Debug, Clone, Default, Deserialize, Serialize)]\npub struct Session {\n    #[serde(rename(serialize = \"model\", deserialize = \"model\"))]\n    model_id: String,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    temperature: Option<f64>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    top_p: Option<f64>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    use_tools: Option<String>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    save_session: Option<bool>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    compress_threshold: Option<usize>,\n\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    role_name: Option<String>,\n    #[serde(default, skip_serializing_if = \"IndexMap::is_empty\")]\n    agent_variables: AgentVariables,\n    #[serde(default, skip_serializing_if = \"String::is_empty\")]\n    agent_instructions: String,\n\n    #[serde(default, skip_serializing_if = \"Vec::is_empty\")]\n    compressed_messages: Vec<Message>,\n    messages: Vec<Message>,\n    #[serde(default, skip_serializing_if = \"HashMap::is_empty\")]\n    data_urls: HashMap<String, String>,\n\n    #[serde(skip)]\n    model: Model,\n    #[serde(skip)]\n    role_prompt: String,\n    #[serde(skip)]\n    name: String,\n    #[serde(skip)]\n    path: Option<String>,\n    #[serde(skip)]\n    dirty: bool,\n    #[serde(skip)]\n    save_session_this_time: bool,\n    #[serde(skip)]\n    compressing: bool,\n    #[serde(skip)]\n    autoname: Option<AutoName>,\n    #[serde(skip)]\n    tokens: usize,\n}\n\nimpl Session {\n    pub fn new(config: &Config, name: &str) -> Self {\n        let role = config.extract_role();\n        let mut session = Self {\n            name: name.to_string(),\n            save_session: config.save_session,\n            ..Default::default()\n        };\n        session.set_role(role);\n        session.dirty = false;\n        session\n    }\n\n    pub fn load(config: &Config, name: &str, path: &Path) -> Result<Self> {\n        let content = read_to_string(path)\n            .with_context(|| format!(\"Failed to load session {} at {}\", name, path.display()))?;\n        let mut session: Self =\n            serde_yaml::from_str(&content).with_context(|| format!(\"Invalid session {name}\"))?;\n\n        session.model = Model::retrieve_model(config, &session.model_id, ModelType::Chat)?;\n\n        if let Some(autoname) = name.strip_prefix(\"_/\") {\n            session.name = TEMP_SESSION_NAME.to_string();\n            session.path = None;\n            if let Ok(true) = RE_AUTONAME_PREFIX.is_match(autoname) {\n                session.autoname = Some(AutoName::new(autoname[16..].to_string()));\n            }\n        } else {\n            session.name = name.to_string();\n            session.path = Some(path.display().to_string());\n        }\n\n        if let Some(role_name) = &session.role_name {\n            if let Ok(role) = config.retrieve_role(role_name) {\n                session.role_prompt = role.prompt().to_string();\n            }\n        }\n\n        session.update_tokens();\n\n        Ok(session)\n    }\n\n    pub fn is_empty(&self) -> bool {\n        self.messages.is_empty() && self.compressed_messages.is_empty()\n    }\n\n    pub fn name(&self) -> &str {\n        &self.name\n    }\n\n    pub fn role_name(&self) -> Option<&str> {\n        self.role_name.as_deref()\n    }\n\n    pub fn dirty(&self) -> bool {\n        self.dirty\n    }\n\n    pub fn save_session(&self) -> Option<bool> {\n        self.save_session\n    }\n\n    pub fn tokens(&self) -> usize {\n        self.tokens\n    }\n\n    pub fn update_tokens(&mut self) {\n        self.tokens = self.model().total_tokens(&self.messages);\n    }\n\n    pub fn has_user_messages(&self) -> bool {\n        self.messages.iter().any(|v| v.role.is_user())\n    }\n\n    pub fn user_messages_len(&self) -> usize {\n        self.messages.iter().filter(|v| v.role.is_user()).count()\n    }\n\n    pub fn export(&self) -> Result<String> {\n        let mut data = json!({\n            \"path\": self.path,\n            \"model\": self.model().id(),\n        });\n        if let Some(temperature) = self.temperature() {\n            data[\"temperature\"] = temperature.into();\n        }\n        if let Some(top_p) = self.top_p() {\n            data[\"top_p\"] = top_p.into();\n        }\n        if let Some(use_tools) = self.use_tools() {\n            data[\"use_tools\"] = use_tools.into();\n        }\n        if let Some(save_session) = self.save_session() {\n            data[\"save_session\"] = save_session.into();\n        }\n        let (tokens, percent) = self.tokens_usage();\n        data[\"total_tokens\"] = tokens.into();\n        if let Some(max_input_tokens) = self.model().max_input_tokens() {\n            data[\"max_input_tokens\"] = max_input_tokens.into();\n        }\n        if percent != 0.0 {\n            data[\"total/max\"] = format!(\"{percent}%\").into();\n        }\n        data[\"messages\"] = json!(self.messages);\n\n        let output = serde_yaml::to_string(&data)\n            .with_context(|| format!(\"Unable to show info about session '{}'\", &self.name))?;\n        Ok(output)\n    }\n\n    pub fn render(\n        &self,\n        render: &mut MarkdownRender,\n        agent_info: &Option<(String, Vec<String>)>,\n    ) -> Result<String> {\n        let mut items = vec![];\n\n        if let Some(path) = &self.path {\n            items.push((\"path\", path.to_string()));\n        }\n\n        if let Some(autoname) = self.autoname() {\n            items.push((\"autoname\", autoname.to_string()));\n        }\n\n        items.push((\"model\", self.model().id()));\n\n        if let Some(temperature) = self.temperature() {\n            items.push((\"temperature\", temperature.to_string()));\n        }\n        if let Some(top_p) = self.top_p() {\n            items.push((\"top_p\", top_p.to_string()));\n        }\n\n        if let Some(use_tools) = self.use_tools() {\n            items.push((\"use_tools\", use_tools));\n        }\n\n        if let Some(save_session) = self.save_session() {\n            items.push((\"save_session\", save_session.to_string()));\n        }\n\n        if let Some(compress_threshold) = self.compress_threshold {\n            items.push((\"compress_threshold\", compress_threshold.to_string()));\n        }\n\n        if let Some(max_input_tokens) = self.model().max_input_tokens() {\n            items.push((\"max_input_tokens\", max_input_tokens.to_string()));\n        }\n\n        let mut lines: Vec<String> = items\n            .iter()\n            .map(|(name, value)| format!(\"{name:<20}{value}\"))\n            .collect();\n\n        lines.push(String::new());\n\n        if !self.is_empty() {\n            let resolve_url_fn = |url: &str| resolve_data_url(&self.data_urls, url.to_string());\n\n            for message in &self.messages {\n                match message.role {\n                    MessageRole::System => {\n                        lines.push(\n                            render\n                                .render(&message.content.render_input(resolve_url_fn, agent_info)),\n                        );\n                    }\n                    MessageRole::Assistant => {\n                        if let MessageContent::Text(text) = &message.content {\n                            lines.push(render.render(text));\n                        }\n                        lines.push(\"\".into());\n                    }\n                    MessageRole::User => {\n                        lines.push(format!(\n                            \">> {}\",\n                            message.content.render_input(resolve_url_fn, agent_info)\n                        ));\n                    }\n                    MessageRole::Tool => {\n                        lines.push(message.content.render_input(resolve_url_fn, agent_info));\n                    }\n                }\n            }\n        }\n\n        Ok(lines.join(\"\\n\"))\n    }\n\n    pub fn tokens_usage(&self) -> (usize, f32) {\n        let tokens = self.tokens();\n        let max_input_tokens = self.model().max_input_tokens().unwrap_or_default();\n        let percent = if max_input_tokens == 0 {\n            0.0\n        } else {\n            let percent = tokens as f32 / max_input_tokens as f32 * 100.0;\n            (percent * 100.0).round() / 100.0\n        };\n        (tokens, percent)\n    }\n\n    pub fn set_role(&mut self, role: Role) {\n        self.model_id = role.model().id();\n        self.temperature = role.temperature();\n        self.top_p = role.top_p();\n        self.use_tools = role.use_tools();\n        self.model = role.model().clone();\n        self.role_name = convert_option_string(role.name());\n        self.role_prompt = role.prompt().to_string();\n        self.dirty = true;\n        self.update_tokens();\n    }\n\n    pub fn clear_role(&mut self) {\n        self.role_name = None;\n        self.role_prompt.clear();\n    }\n\n    pub fn sync_agent(&mut self, agent: &Agent) {\n        self.role_name = None;\n        self.role_prompt = agent.interpolated_instructions();\n        self.agent_variables = agent.variables().clone();\n        self.agent_instructions = self.role_prompt.clone();\n    }\n\n    pub fn agent_variables(&self) -> &AgentVariables {\n        &self.agent_variables\n    }\n\n    pub fn agent_instructions(&self) -> &str {\n        &self.agent_instructions\n    }\n\n    pub fn set_save_session(&mut self, value: Option<bool>) {\n        if self.save_session != value {\n            self.save_session = value;\n            self.dirty = true;\n        }\n    }\n\n    pub fn set_save_session_this_time(&mut self) {\n        self.save_session_this_time = true;\n    }\n\n    pub fn set_compress_threshold(&mut self, value: Option<usize>) {\n        if self.compress_threshold != value {\n            self.compress_threshold = value;\n            self.dirty = true;\n        }\n    }\n\n    pub fn need_compress(&self, global_compress_threshold: usize) -> bool {\n        if self.compressing {\n            return false;\n        }\n        let threshold = self.compress_threshold.unwrap_or(global_compress_threshold);\n        if threshold < 1 {\n            return false;\n        }\n        self.tokens() > threshold\n    }\n\n    pub fn compressing(&self) -> bool {\n        self.compressing\n    }\n\n    pub fn set_compressing(&mut self, compressing: bool) {\n        self.compressing = compressing;\n    }\n\n    pub fn compress(&mut self, mut prompt: String) {\n        if let Some(system_prompt) = self.messages.first().and_then(|v| {\n            if MessageRole::System == v.role {\n                let content = v.content.to_text();\n                if !content.is_empty() {\n                    return Some(content);\n                }\n            }\n            None\n        }) {\n            prompt = format!(\"{system_prompt}\\n\\n{prompt}\",);\n        }\n        self.compressed_messages.append(&mut self.messages);\n        self.messages.push(Message::new(\n            MessageRole::System,\n            MessageContent::Text(prompt),\n        ));\n        self.dirty = true;\n        self.update_tokens();\n    }\n\n    pub fn need_autoname(&self) -> bool {\n        self.autoname.as_ref().map(|v| v.need()).unwrap_or_default()\n    }\n\n    pub fn set_autonaming(&mut self, naming: bool) {\n        if let Some(v) = self.autoname.as_mut() {\n            v.naming = naming;\n        }\n    }\n\n    pub fn chat_history_for_autonaming(&self) -> Option<String> {\n        self.autoname.as_ref().and_then(|v| v.chat_history.clone())\n    }\n\n    pub fn autoname(&self) -> Option<&str> {\n        self.autoname.as_ref().and_then(|v| v.name.as_deref())\n    }\n\n    pub fn set_autoname(&mut self, value: &str) {\n        let name = value\n            .chars()\n            .map(|v| if v.is_alphanumeric() { v } else { '-' })\n            .collect();\n        self.autoname = Some(AutoName::new(name));\n    }\n\n    pub fn exit(&mut self, session_dir: &Path, is_repl: bool) -> Result<()> {\n        let mut save_session = self.save_session();\n        if self.save_session_this_time {\n            save_session = Some(true);\n        }\n        if self.dirty && save_session != Some(false) {\n            let mut session_dir = session_dir.to_path_buf();\n            let mut session_name = self.name().to_string();\n            if save_session.is_none() {\n                if !is_repl {\n                    return Ok(());\n                }\n                let ans = Confirm::new(\"Save session?\").with_default(false).prompt()?;\n                if !ans {\n                    return Ok(());\n                }\n                if session_name == TEMP_SESSION_NAME {\n                    session_name = Text::new(\"Session name:\")\n                        .with_validator(|input: &str| {\n                            let input = input.trim();\n                            if input.is_empty() {\n                                Ok(Validation::Invalid(\"This name is required\".into()))\n                            } else if input == TEMP_SESSION_NAME {\n                                Ok(Validation::Invalid(\"This name is reserved\".into()))\n                            } else {\n                                Ok(Validation::Valid)\n                            }\n                        })\n                        .prompt()?;\n                }\n            } else if save_session == Some(true) && session_name == TEMP_SESSION_NAME {\n                session_dir = session_dir.join(\"_\");\n                ensure_parent_exists(&session_dir).with_context(|| {\n                    format!(\"Failed to create directory '{}'\", session_dir.display())\n                })?;\n\n                let now = chrono::Local::now();\n                session_name = now.format(\"%Y%m%dT%H%M%S\").to_string();\n                if let Some(autoname) = self.autoname() {\n                    session_name = format!(\"{session_name}-{autoname}\")\n                }\n            }\n            let session_path = session_dir.join(format!(\"{session_name}.yaml\"));\n            self.save(&session_name, &session_path, is_repl)?;\n        }\n        Ok(())\n    }\n\n    pub fn save(&mut self, session_name: &str, session_path: &Path, is_repl: bool) -> Result<()> {\n        ensure_parent_exists(session_path)?;\n\n        self.path = Some(session_path.display().to_string());\n\n        let content = serde_yaml::to_string(&self)\n            .with_context(|| format!(\"Failed to serde session '{}'\", self.name))?;\n        write(session_path, content).with_context(|| {\n            format!(\n                \"Failed to write session '{}' to '{}'\",\n                self.name,\n                session_path.display()\n            )\n        })?;\n\n        if is_repl {\n            println!(\"✓ Saved the session to '{}'.\", session_path.display());\n        }\n\n        if self.name() != session_name {\n            self.name = session_name.to_string()\n        }\n\n        self.dirty = false;\n\n        Ok(())\n    }\n\n    pub fn guard_empty(&self) -> Result<()> {\n        if !self.is_empty() {\n            bail!(\"Cannot perform this operation because the session has messages, please `.empty session` first.\");\n        }\n        Ok(())\n    }\n\n    pub fn add_message(&mut self, input: &Input, output: &str) -> Result<()> {\n        if input.continue_output().is_some() {\n            if let Some(message) = self.messages.last_mut() {\n                if let MessageContent::Text(text) = &mut message.content {\n                    *text = format!(\"{text}{output}\");\n                }\n            }\n        } else if input.regenerate() {\n            if let Some(message) = self.messages.last_mut() {\n                if let MessageContent::Text(text) = &mut message.content {\n                    *text = output.to_string();\n                }\n            }\n        } else {\n            if self.messages.is_empty() {\n                if self.name == TEMP_SESSION_NAME && self.save_session == Some(true) {\n                    let raw_input = input.raw();\n                    let chat_history = format!(\"USER: {raw_input}\\nASSISTANT: {output}\\n\");\n                    self.autoname = Some(AutoName::new_from_chat_history(chat_history));\n                }\n                self.messages.extend(input.role().build_messages(input));\n            } else {\n                self.messages\n                    .push(Message::new(MessageRole::User, input.message_content()));\n            }\n            self.data_urls.extend(input.data_urls());\n            if let Some(tool_calls) = input.tool_calls() {\n                self.messages.push(Message::new(\n                    MessageRole::Tool,\n                    MessageContent::ToolCalls(tool_calls.clone()),\n                ))\n            }\n            self.messages.push(Message::new(\n                MessageRole::Assistant,\n                MessageContent::Text(output.to_string()),\n            ));\n        }\n        self.dirty = true;\n        self.update_tokens();\n        Ok(())\n    }\n\n    pub fn clear_messages(&mut self) {\n        self.messages.clear();\n        self.compressed_messages.clear();\n        self.data_urls.clear();\n        self.autoname = None;\n        self.dirty = true;\n        self.update_tokens();\n    }\n\n    pub fn echo_messages(&self, input: &Input) -> String {\n        let messages = self.build_messages(input);\n        serde_yaml::to_string(&messages).unwrap_or_else(|_| \"Unable to echo message\".into())\n    }\n\n    pub fn build_messages(&self, input: &Input) -> Vec<Message> {\n        let mut messages = self.messages.clone();\n        if input.continue_output().is_some() {\n            return messages;\n        } else if input.regenerate() {\n            while let Some(last) = messages.last() {\n                if !last.role.is_user() {\n                    messages.pop();\n                } else {\n                    break;\n                }\n            }\n            return messages;\n        }\n        let mut need_add_msg = true;\n        let len = messages.len();\n        if len == 0 {\n            messages = input.role().build_messages(input);\n            need_add_msg = false;\n        } else if len == 1 && self.compressed_messages.len() >= 2 {\n            if let Some(index) = self\n                .compressed_messages\n                .iter()\n                .rposition(|v| v.role == MessageRole::User)\n            {\n                messages.extend(self.compressed_messages[index..].to_vec());\n            }\n        }\n        if need_add_msg {\n            messages.push(Message::new(MessageRole::User, input.message_content()));\n        }\n        messages\n    }\n}\n\nimpl RoleLike for Session {\n    fn to_role(&self) -> Role {\n        let role_name = self.role_name.as_deref().unwrap_or_default();\n        let mut role = Role::new(role_name, &self.role_prompt);\n        role.sync(self);\n        role\n    }\n\n    fn model(&self) -> &Model {\n        &self.model\n    }\n\n    fn temperature(&self) -> Option<f64> {\n        self.temperature\n    }\n\n    fn top_p(&self) -> Option<f64> {\n        self.top_p\n    }\n\n    fn use_tools(&self) -> Option<String> {\n        self.use_tools.clone()\n    }\n\n    fn set_model(&mut self, model: Model) {\n        if self.model().id() != model.id() {\n            self.model_id = model.id();\n            self.model = model;\n            self.dirty = true;\n            self.update_tokens();\n        }\n    }\n\n    fn set_temperature(&mut self, value: Option<f64>) {\n        if self.temperature != value {\n            self.temperature = value;\n            self.dirty = true;\n        }\n    }\n\n    fn set_top_p(&mut self, value: Option<f64>) {\n        if self.top_p != value {\n            self.top_p = value;\n            self.dirty = true;\n        }\n    }\n\n    fn set_use_tools(&mut self, value: Option<String>) {\n        if self.use_tools != value {\n            self.use_tools = value;\n            self.dirty = true;\n        }\n    }\n}\n\n#[derive(Debug, Clone, Default)]\nstruct AutoName {\n    naming: bool,\n    chat_history: Option<String>,\n    name: Option<String>,\n}\n\nimpl AutoName {\n    pub fn new(name: String) -> Self {\n        Self {\n            name: Some(name),\n            ..Default::default()\n        }\n    }\n    pub fn new_from_chat_history(chat_history: String) -> Self {\n        Self {\n            chat_history: Some(chat_history),\n            ..Default::default()\n        }\n    }\n    pub fn need(&self) -> bool {\n        !self.naming && self.chat_history.is_some() && self.name.is_none()\n    }\n}\n"
  },
  {
    "path": "src/function.rs",
    "content": "use crate::{\n    config::{Agent, Config, GlobalConfig},\n    utils::*,\n};\n\nuse anyhow::{anyhow, bail, Context, Result};\nuse indexmap::IndexMap;\nuse serde::{Deserialize, Serialize};\nuse serde_json::{json, Value};\nuse std::{\n    collections::{HashMap, HashSet},\n    fs,\n    path::{Path, PathBuf},\n};\n\n#[cfg(windows)]\nconst PATH_SEP: &str = \";\";\n#[cfg(not(windows))]\nconst PATH_SEP: &str = \":\";\n\npub fn eval_tool_calls(config: &GlobalConfig, mut calls: Vec<ToolCall>) -> Result<Vec<ToolResult>> {\n    let mut output = vec![];\n    if calls.is_empty() {\n        return Ok(output);\n    }\n    calls = ToolCall::dedup(calls);\n    if calls.is_empty() {\n        bail!(\"The request was aborted because an infinite loop of function calls was detected.\")\n    }\n    let mut is_all_null = true;\n    for call in calls {\n        let mut result = call.eval(config)?;\n        if result.is_null() {\n            result = json!(\"DONE\");\n        } else {\n            is_all_null = false;\n        }\n        output.push(ToolResult::new(call, result));\n    }\n    if is_all_null {\n        output = vec![];\n    }\n    Ok(output)\n}\n\n#[derive(Debug, Clone, Deserialize, Serialize)]\npub struct ToolResult {\n    pub call: ToolCall,\n    pub output: Value,\n}\n\nimpl ToolResult {\n    pub fn new(call: ToolCall, output: Value) -> Self {\n        Self { call, output }\n    }\n}\n\n#[derive(Debug, Clone, Default)]\npub struct Functions {\n    declarations: Vec<FunctionDeclaration>,\n}\n\nimpl Functions {\n    pub fn init(declarations_path: &Path) -> Result<Self> {\n        let declarations: Vec<FunctionDeclaration> = if declarations_path.exists() {\n            let ctx = || {\n                format!(\n                    \"Failed to load functions at {}\",\n                    declarations_path.display()\n                )\n            };\n            let content = fs::read_to_string(declarations_path).with_context(ctx)?;\n            serde_json::from_str(&content).with_context(ctx)?\n        } else {\n            vec![]\n        };\n\n        Ok(Self { declarations })\n    }\n\n    pub fn find(&self, name: &str) -> Option<&FunctionDeclaration> {\n        self.declarations.iter().find(|v| v.name == name)\n    }\n\n    pub fn contains(&self, name: &str) -> bool {\n        self.declarations.iter().any(|v| v.name == name)\n    }\n\n    pub fn declarations(&self) -> &[FunctionDeclaration] {\n        &self.declarations\n    }\n\n    pub fn is_empty(&self) -> bool {\n        self.declarations.is_empty()\n    }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct FunctionDeclaration {\n    pub name: String,\n    pub description: String,\n    pub parameters: JsonSchema,\n    #[serde(skip_serializing, default)]\n    pub agent: bool,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct JsonSchema {\n    #[serde(rename = \"type\", skip_serializing_if = \"Option::is_none\")]\n    pub type_value: Option<String>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub description: Option<String>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub properties: Option<IndexMap<String, JsonSchema>>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub items: Option<Box<JsonSchema>>,\n    #[serde(rename = \"anyOf\", skip_serializing_if = \"Option::is_none\")]\n    pub any_of: Option<Vec<JsonSchema>>,\n    #[serde(rename = \"enum\", skip_serializing_if = \"Option::is_none\")]\n    pub enum_value: Option<Vec<String>>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub default: Option<Value>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub required: Option<Vec<String>>,\n}\n\nimpl JsonSchema {\n    pub fn is_empty_properties(&self) -> bool {\n        match &self.properties {\n            Some(v) => v.is_empty(),\n            None => true,\n        }\n    }\n}\n\n#[derive(Debug, Clone, Default, Deserialize, Serialize)]\npub struct ToolCall {\n    pub name: String,\n    pub arguments: Value,\n    pub id: Option<String>,\n}\n\ntype CallConfig = (String, String, Vec<String>, HashMap<String, String>);\n\nimpl ToolCall {\n    pub fn dedup(calls: Vec<Self>) -> Vec<Self> {\n        let mut new_calls = vec![];\n        let mut seen_ids = HashSet::new();\n\n        for call in calls.into_iter().rev() {\n            if let Some(id) = &call.id {\n                if !seen_ids.contains(id) {\n                    seen_ids.insert(id.clone());\n                    new_calls.push(call);\n                }\n            } else {\n                new_calls.push(call);\n            }\n        }\n\n        new_calls.reverse();\n        new_calls\n    }\n\n    pub fn new(name: String, arguments: Value, id: Option<String>) -> Self {\n        Self {\n            name,\n            arguments,\n            id,\n        }\n    }\n\n    pub fn eval(&self, config: &GlobalConfig) -> Result<Value> {\n        let (call_name, cmd_name, mut cmd_args, envs) = match &config.read().agent {\n            Some(agent) => self.extract_call_config_from_agent(config, agent)?,\n            None => self.extract_call_config_from_config(config)?,\n        };\n\n        let json_data = if self.arguments.is_object() {\n            self.arguments.clone()\n        } else if let Some(arguments) = self.arguments.as_str() {\n            let arguments: Value = serde_json::from_str(arguments).map_err(|_| {\n                anyhow!(\"The call '{call_name}' has invalid arguments: {arguments}\")\n            })?;\n            arguments\n        } else {\n            bail!(\n                \"The call '{call_name}' has invalid arguments: {}\",\n                self.arguments\n            );\n        };\n\n        cmd_args.push(json_data.to_string());\n\n        let output = match run_llm_function(cmd_name, cmd_args, envs)? {\n            Some(contents) => serde_json::from_str(&contents)\n                .ok()\n                .unwrap_or_else(|| json!({\"output\": contents})),\n            None => Value::Null,\n        };\n\n        Ok(output)\n    }\n\n    fn extract_call_config_from_agent(\n        &self,\n        config: &GlobalConfig,\n        agent: &Agent,\n    ) -> Result<CallConfig> {\n        let function_name = self.name.clone();\n        match agent.functions().find(&function_name) {\n            Some(function) => {\n                let agent_name = agent.name().to_string();\n                if function.agent {\n                    Ok((\n                        format!(\"{agent_name}-{function_name}\"),\n                        agent_name,\n                        vec![function_name],\n                        agent.variable_envs(),\n                    ))\n                } else {\n                    Ok((\n                        function_name.clone(),\n                        function_name,\n                        vec![],\n                        Default::default(),\n                    ))\n                }\n            }\n            None => self.extract_call_config_from_config(config),\n        }\n    }\n\n    fn extract_call_config_from_config(&self, config: &GlobalConfig) -> Result<CallConfig> {\n        let function_name = self.name.clone();\n        match config.read().functions.contains(&function_name) {\n            true => Ok((\n                function_name.clone(),\n                function_name,\n                vec![],\n                Default::default(),\n            )),\n            false => bail!(\"Unexpected call: {function_name} {}\", self.arguments),\n        }\n    }\n}\n\npub fn run_llm_function(\n    cmd_name: String,\n    cmd_args: Vec<String>,\n    mut envs: HashMap<String, String>,\n) -> Result<Option<String>> {\n    let prompt = format!(\"Call {cmd_name} {}\", cmd_args.join(\" \"));\n\n    let mut bin_dirs: Vec<PathBuf> = vec![];\n    if cmd_args.len() > 1 {\n        let dir = Config::agent_functions_dir(&cmd_name).join(\"bin\");\n        if dir.exists() {\n            bin_dirs.push(dir);\n        }\n    }\n    bin_dirs.push(Config::functions_bin_dir());\n    let current_path = std::env::var(\"PATH\").context(\"No PATH environment variable\")?;\n    let prepend_path = bin_dirs\n        .iter()\n        .map(|v| format!(\"{}{PATH_SEP}\", v.display()))\n        .collect::<Vec<_>>()\n        .join(\"\");\n    envs.insert(\"PATH\".into(), format!(\"{prepend_path}{current_path}\"));\n\n    let temp_file = temp_file(\"-eval-\", \"\");\n    envs.insert(\"LLM_OUTPUT\".into(), temp_file.display().to_string());\n\n    #[cfg(windows)]\n    let cmd_name = polyfill_cmd_name(&cmd_name, &bin_dirs);\n    if *IS_STDOUT_TERMINAL {\n        println!(\"{}\", dimmed_text(&prompt));\n    }\n    let exit_code = run_command(&cmd_name, &cmd_args, Some(envs))\n        .map_err(|err| anyhow!(\"Unable to run {cmd_name}, {err}\"))?;\n    if exit_code != 0 {\n        bail!(\"Tool call exit with {exit_code}\");\n    }\n    let mut output = None;\n    if temp_file.exists() {\n        let contents =\n            fs::read_to_string(temp_file).context(\"Failed to retrieve tool call output\")?;\n        if !contents.is_empty() {\n            output = Some(contents);\n        }\n    };\n    Ok(output)\n}\n\n#[cfg(windows)]\nfn polyfill_cmd_name<T: AsRef<Path>>(cmd_name: &str, bin_dir: &[T]) -> String {\n    let cmd_name = cmd_name.to_string();\n    if let Ok(exts) = std::env::var(\"PATHEXT\") {\n        for name in exts.split(';').map(|ext| format!(\"{cmd_name}{ext}\")) {\n            for dir in bin_dir {\n                let path = dir.as_ref().join(&name);\n                if path.exists() {\n                    return name.to_string();\n                }\n            }\n        }\n    }\n    cmd_name\n}\n"
  },
  {
    "path": "src/main.rs",
    "content": "mod cli;\nmod client;\nmod config;\nmod function;\nmod rag;\nmod render;\nmod repl;\nmod serve;\n#[macro_use]\nmod utils;\n\n#[macro_use]\nextern crate log;\n\nuse crate::cli::Cli;\nuse crate::client::{\n    call_chat_completions, call_chat_completions_streaming, list_models, ModelType,\n};\nuse crate::config::{\n    ensure_parent_exists, list_agents, load_env_file, macro_execute, Config, GlobalConfig, Input,\n    WorkingMode, CODE_ROLE, EXPLAIN_SHELL_ROLE, SHELL_ROLE, TEMP_SESSION_NAME,\n};\nuse crate::render::render_error;\nuse crate::repl::Repl;\nuse crate::utils::*;\n\nuse anyhow::{bail, Result};\nuse clap::Parser;\nuse inquire::Text;\nuse parking_lot::RwLock;\nuse simplelog::{format_description, ConfigBuilder, LevelFilter, SimpleLogger, WriteLogger};\nuse std::{env, process, sync::Arc};\n\n#[tokio::main]\nasync fn main() -> Result<()> {\n    load_env_file()?;\n    let cli = Cli::parse();\n    let text = cli.text()?;\n    let working_mode = if cli.serve.is_some() {\n        WorkingMode::Serve\n    } else if text.is_none() && cli.file.is_empty() {\n        WorkingMode::Repl\n    } else {\n        WorkingMode::Cmd\n    };\n    let info_flag = cli.info\n        || cli.sync_models\n        || cli.list_models\n        || cli.list_roles\n        || cli.list_agents\n        || cli.list_rags\n        || cli.list_macros\n        || cli.list_sessions;\n    setup_logger(working_mode.is_serve())?;\n    let config = Arc::new(RwLock::new(Config::init(working_mode, info_flag).await?));\n    if let Err(err) = run(config, cli, text).await {\n        render_error(err);\n        std::process::exit(1);\n    }\n    Ok(())\n}\n\nasync fn run(config: GlobalConfig, cli: Cli, text: Option<String>) -> Result<()> {\n    let abort_signal = create_abort_signal();\n\n    if cli.sync_models {\n        let url = config.read().sync_models_url();\n        return Config::sync_models(&url, abort_signal.clone()).await;\n    }\n\n    if cli.list_models {\n        for model in list_models(&config.read(), ModelType::Chat) {\n            println!(\"{}\", model.id());\n        }\n        return Ok(());\n    }\n    if cli.list_roles {\n        let roles = Config::list_roles(true).join(\"\\n\");\n        println!(\"{roles}\");\n        return Ok(());\n    }\n    if cli.list_agents {\n        let agents = list_agents().join(\"\\n\");\n        println!(\"{agents}\");\n        return Ok(());\n    }\n    if cli.list_rags {\n        let rags = Config::list_rags().join(\"\\n\");\n        println!(\"{rags}\");\n        return Ok(());\n    }\n    if cli.list_macros {\n        let macros = Config::list_macros().join(\"\\n\");\n        println!(\"{macros}\");\n        return Ok(());\n    }\n\n    if cli.dry_run {\n        config.write().dry_run = true;\n    }\n\n    if let Some(agent) = &cli.agent {\n        let session = cli.session.as_ref().map(|v| match v {\n            Some(v) => v.as_str(),\n            None => TEMP_SESSION_NAME,\n        });\n        if !cli.agent_variable.is_empty() {\n            config.write().agent_variables = Some(\n                cli.agent_variable\n                    .chunks(2)\n                    .map(|v| (v[0].to_string(), v[1].to_string()))\n                    .collect(),\n            );\n        }\n\n        let ret = Config::use_agent(&config, agent, session, abort_signal.clone()).await;\n        config.write().agent_variables = None;\n        ret?;\n    } else {\n        if let Some(prompt) = &cli.prompt {\n            config.write().use_prompt(prompt)?;\n        } else if let Some(name) = &cli.role {\n            config.write().use_role(name)?;\n        } else if cli.execute {\n            config.write().use_role(SHELL_ROLE)?;\n        } else if cli.code {\n            config.write().use_role(CODE_ROLE)?;\n        }\n        if let Some(session) = &cli.session {\n            config\n                .write()\n                .use_session(session.as_ref().map(|v| v.as_str()))?;\n        }\n        if let Some(rag) = &cli.rag {\n            Config::use_rag(&config, Some(rag), abort_signal.clone()).await?;\n        }\n    }\n    if cli.list_sessions {\n        let sessions = config.read().list_sessions().join(\"\\n\");\n        println!(\"{sessions}\");\n        return Ok(());\n    }\n    if let Some(model_id) = &cli.model {\n        config.write().set_model(model_id)?;\n    }\n    if cli.no_stream {\n        config.write().stream = false;\n    }\n    if cli.empty_session {\n        config.write().empty_session()?;\n    }\n    if cli.save_session {\n        config.write().set_save_session_this_time()?;\n    }\n    if cli.info {\n        let info = config.read().info()?;\n        println!(\"{info}\");\n        return Ok(());\n    }\n    if let Some(addr) = cli.serve {\n        return serve::run(config, addr).await;\n    }\n    let is_repl = config.read().working_mode.is_repl();\n    if cli.rebuild_rag {\n        Config::rebuild_rag(&config, abort_signal.clone()).await?;\n        if is_repl {\n            return Ok(());\n        }\n    }\n    if let Some(name) = &cli.macro_name {\n        macro_execute(&config, name, text.as_deref(), abort_signal.clone()).await?;\n        return Ok(());\n    }\n    if cli.execute && !is_repl {\n        let input = create_input(&config, text, &cli.file, abort_signal.clone()).await?;\n        shell_execute(&config, &SHELL, input, abort_signal.clone()).await?;\n        return Ok(());\n    }\n    config.write().apply_prelude()?;\n    match is_repl {\n        false => {\n            let mut input = create_input(&config, text, &cli.file, abort_signal.clone()).await?;\n            input.use_embeddings(abort_signal.clone()).await?;\n            start_directive(&config, input, cli.code, abort_signal).await\n        }\n        true => {\n            if !*IS_STDOUT_TERMINAL {\n                bail!(\"No TTY for REPL\")\n            }\n            start_interactive(&config).await\n        }\n    }\n}\n\n#[async_recursion::async_recursion]\nasync fn start_directive(\n    config: &GlobalConfig,\n    input: Input,\n    code_mode: bool,\n    abort_signal: AbortSignal,\n) -> Result<()> {\n    let client = input.create_client()?;\n    let extract_code = !*IS_STDOUT_TERMINAL && code_mode;\n    config.write().before_chat_completion(&input)?;\n    let (output, tool_results) = if !input.stream() || extract_code {\n        call_chat_completions(\n            &input,\n            true,\n            extract_code,\n            client.as_ref(),\n            abort_signal.clone(),\n        )\n        .await?\n    } else {\n        call_chat_completions_streaming(&input, client.as_ref(), abort_signal.clone()).await?\n    };\n    config\n        .write()\n        .after_chat_completion(&input, &output, &tool_results)?;\n\n    if !tool_results.is_empty() {\n        start_directive(\n            config,\n            input.merge_tool_results(output, tool_results),\n            code_mode,\n            abort_signal,\n        )\n        .await?;\n    }\n\n    config.write().exit_session()?;\n    Ok(())\n}\n\nasync fn start_interactive(config: &GlobalConfig) -> Result<()> {\n    let mut repl: Repl = Repl::init(config)?;\n    repl.run().await\n}\n\n#[async_recursion::async_recursion]\nasync fn shell_execute(\n    config: &GlobalConfig,\n    shell: &Shell,\n    mut input: Input,\n    abort_signal: AbortSignal,\n) -> Result<()> {\n    let client = input.create_client()?;\n    config.write().before_chat_completion(&input)?;\n    let (eval_str, _) =\n        call_chat_completions(&input, false, true, client.as_ref(), abort_signal.clone()).await?;\n\n    config\n        .write()\n        .after_chat_completion(&input, &eval_str, &[])?;\n    if eval_str.is_empty() {\n        bail!(\"No command generated\");\n    }\n    if config.read().dry_run {\n        config.read().print_markdown(&eval_str)?;\n        return Ok(());\n    }\n    if *IS_STDOUT_TERMINAL {\n        let options = [\"execute\", \"revise\", \"describe\", \"copy\", \"quit\"];\n        let command = color_text(eval_str.trim(), nu_ansi_term::Color::Rgb(255, 165, 0));\n        let first_letter_color = nu_ansi_term::Color::Cyan;\n        let prompt_text = options\n            .iter()\n            .map(|v| format!(\"{}{}\", color_text(&v[0..1], first_letter_color), &v[1..]))\n            .collect::<Vec<String>>()\n            .join(&dimmed_text(\" | \"));\n        loop {\n            println!(\"{command}\");\n            let answer_char =\n                read_single_key(&['e', 'r', 'd', 'c', 'q'], 'e', &format!(\"{prompt_text}: \"))?;\n\n            match answer_char {\n                'e' => {\n                    debug!(\"{} {:?}\", shell.cmd, &[&shell.arg, &eval_str]);\n                    let code = run_command(&shell.cmd, &[&shell.arg, &eval_str], None)?;\n                    if code == 0 && config.read().save_shell_history {\n                        let _ = append_to_shell_history(&shell.name, &eval_str, code);\n                    }\n                    process::exit(code);\n                }\n                'r' => {\n                    let revision = Text::new(\"Enter your revision:\").prompt()?;\n                    let text = format!(\"{}\\n{revision}\", input.text());\n                    input.set_text(text);\n                    return shell_execute(config, shell, input, abort_signal.clone()).await;\n                }\n                'd' => {\n                    let role = config.read().retrieve_role(EXPLAIN_SHELL_ROLE)?;\n                    let input = Input::from_str(config, &eval_str, Some(role));\n                    if input.stream() {\n                        call_chat_completions_streaming(\n                            &input,\n                            client.as_ref(),\n                            abort_signal.clone(),\n                        )\n                        .await?;\n                    } else {\n                        call_chat_completions(\n                            &input,\n                            true,\n                            false,\n                            client.as_ref(),\n                            abort_signal.clone(),\n                        )\n                        .await?;\n                    }\n                    println!();\n                    continue;\n                }\n                'c' => {\n                    set_text(&eval_str)?;\n                    println!(\"{}\", dimmed_text(\"✓ Copied the command.\"));\n                }\n                _ => {}\n            }\n            break;\n        }\n    } else {\n        println!(\"{eval_str}\");\n    }\n    Ok(())\n}\n\nasync fn create_input(\n    config: &GlobalConfig,\n    text: Option<String>,\n    file: &[String],\n    abort_signal: AbortSignal,\n) -> Result<Input> {\n    let input = if file.is_empty() {\n        Input::from_str(config, &text.unwrap_or_default(), None)\n    } else {\n        Input::from_files_with_spinner(\n            config,\n            &text.unwrap_or_default(),\n            file.to_vec(),\n            None,\n            abort_signal,\n        )\n        .await?\n    };\n    if input.is_empty() {\n        bail!(\"No input\");\n    }\n    Ok(input)\n}\n\nfn setup_logger(is_serve: bool) -> Result<()> {\n    let (log_level, log_path) = Config::log_config(is_serve)?;\n    if log_level == LevelFilter::Off {\n        return Ok(());\n    }\n    let crate_name = env!(\"CARGO_CRATE_NAME\");\n    let log_filter = match std::env::var(get_env_name(\"log_filter\")) {\n        Ok(v) => v,\n        Err(_) => match is_serve {\n            true => format!(\"{crate_name}::serve\"),\n            false => crate_name.into(),\n        },\n    };\n    let config = ConfigBuilder::new()\n        .add_filter_allow(log_filter)\n        .set_time_format_custom(format_description!(\n            \"[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:3]Z\"\n        ))\n        .set_thread_level(LevelFilter::Off)\n        .build();\n    match log_path {\n        None => {\n            SimpleLogger::init(log_level, config)?;\n        }\n        Some(log_path) => {\n            ensure_parent_exists(&log_path)?;\n            let log_file = std::fs::File::create(log_path)?;\n            WriteLogger::init(log_level, config, log_file)?;\n        }\n    }\n    Ok(())\n}\n"
  },
  {
    "path": "src/rag/mod.rs",
    "content": "use self::splitter::*;\n\nuse crate::client::*;\nuse crate::config::*;\nuse crate::utils::*;\n\nmod serde_vectors;\nmod splitter;\n\nuse anyhow::{anyhow, bail, Context, Result};\nuse bm25::{Language, SearchEngine, SearchEngineBuilder};\nuse hnsw_rs::prelude::*;\nuse indexmap::{IndexMap, IndexSet};\nuse inquire::{required, validator::Validation, Confirm, Select, Text};\nuse parking_lot::RwLock;\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\nuse std::{collections::HashMap, env, fmt::Debug, fs, hash::Hash, path::Path, time::Duration};\nuse tokio::time::sleep;\n\npub struct Rag {\n    config: GlobalConfig,\n    name: String,\n    path: String,\n    embedding_model: Model,\n    hnsw: Hnsw<'static, f32, DistCosine>,\n    bm25: SearchEngine<DocumentId>,\n    data: RagData,\n    last_sources: RwLock<Option<String>>,\n}\n\nimpl Debug for Rag {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        f.debug_struct(\"Rag\")\n            .field(\"name\", &self.name)\n            .field(\"path\", &self.path)\n            .field(\"embedding_model\", &self.embedding_model)\n            .field(\"data\", &self.data)\n            .finish()\n    }\n}\n\nimpl Clone for Rag {\n    fn clone(&self) -> Self {\n        Self {\n            config: self.config.clone(),\n            name: self.name.clone(),\n            path: self.path.clone(),\n            embedding_model: self.embedding_model.clone(),\n            hnsw: self.data.build_hnsw(),\n            bm25: self.data.build_bm25(),\n            data: self.data.clone(),\n            last_sources: RwLock::new(None),\n        }\n    }\n}\n\nimpl Rag {\n    pub async fn init(\n        config: &GlobalConfig,\n        name: &str,\n        save_path: &Path,\n        doc_paths: &[String],\n        abort_signal: AbortSignal,\n    ) -> Result<Self> {\n        if !*IS_STDOUT_TERMINAL {\n            bail!(\"Failed to init rag in non-interactive mode\");\n        }\n        println!(\"⚙ Initializing RAG...\");\n        let (embedding_model, chunk_size, chunk_overlap) = Self::create_config(config)?;\n        let (reranker_model, top_k) = {\n            let config = config.read();\n            (config.rag_reranker_model.clone(), config.rag_top_k)\n        };\n        let data = RagData::new(\n            embedding_model.id(),\n            chunk_size,\n            chunk_overlap,\n            reranker_model,\n            top_k,\n            embedding_model.max_batch_size(),\n        );\n        let mut rag = Self::create(config, name, save_path, data)?;\n        let mut paths = doc_paths.to_vec();\n        if paths.is_empty() {\n            paths = add_documents()?;\n        };\n        let loaders = config.read().document_loaders.clone();\n        let (spinner, spinner_rx) = Spinner::create(\"\");\n        abortable_run_with_spinner_rx(\n            rag.sync_documents(&paths, true, loaders, Some(spinner)),\n            spinner_rx,\n            abort_signal,\n        )\n        .await?;\n        if rag.save()? {\n            println!(\"✓ Saved RAG to '{}'.\", save_path.display());\n        }\n        Ok(rag)\n    }\n\n    pub fn load(config: &GlobalConfig, name: &str, path: &Path) -> Result<Self> {\n        let err = || format!(\"Failed to load rag '{name}' at '{}'\", path.display());\n        let content = fs::read_to_string(path).with_context(err)?;\n        let data: RagData = serde_yaml::from_str(&content).with_context(err)?;\n        Self::create(config, name, path, data)\n    }\n\n    pub fn create(config: &GlobalConfig, name: &str, path: &Path, data: RagData) -> Result<Self> {\n        let hnsw = data.build_hnsw();\n        let bm25 = data.build_bm25();\n        let embedding_model =\n            Model::retrieve_model(&config.read(), &data.embedding_model, ModelType::Embedding)?;\n        let rag = Rag {\n            config: config.clone(),\n            name: name.to_string(),\n            path: path.display().to_string(),\n            data,\n            embedding_model,\n            hnsw,\n            bm25,\n            last_sources: RwLock::new(None),\n        };\n        Ok(rag)\n    }\n\n    pub fn document_paths(&self) -> &[String] {\n        &self.data.document_paths\n    }\n\n    pub async fn refresh_document_paths(\n        &mut self,\n        document_paths: &[String],\n        refresh: bool,\n        config: &GlobalConfig,\n        abort_signal: AbortSignal,\n    ) -> Result<()> {\n        let loaders = config.read().document_loaders.clone();\n        let (spinner, spinner_rx) = Spinner::create(\"\");\n        abortable_run_with_spinner_rx(\n            self.sync_documents(document_paths, refresh, loaders, Some(spinner)),\n            spinner_rx,\n            abort_signal,\n        )\n        .await?;\n        if self.save()? {\n            println!(\"✓ Saved rag to '{}'.\", self.path);\n        }\n        Ok(())\n    }\n\n    pub fn create_config(config: &GlobalConfig) -> Result<(Model, usize, usize)> {\n        let (embedding_model_id, chunk_size, chunk_overlap) = {\n            let config = config.read();\n            (\n                config.rag_embedding_model.clone(),\n                config.rag_chunk_size,\n                config.rag_chunk_overlap,\n            )\n        };\n        let embedding_model_id = match embedding_model_id {\n            Some(value) => {\n                println!(\"Select embedding model: {value}\");\n                value\n            }\n            None => {\n                let models = list_models(&config.read(), ModelType::Embedding);\n                if models.is_empty() {\n                    bail!(\"No available embedding model\");\n                }\n                select_embedding_model(&models)?\n            }\n        };\n        let embedding_model =\n            Model::retrieve_model(&config.read(), &embedding_model_id, ModelType::Embedding)?;\n\n        let chunk_size = match chunk_size {\n            Some(value) => {\n                println!(\"Set chunk size: {value}\");\n                value\n            }\n            None => set_chunk_size(&embedding_model)?,\n        };\n        let chunk_overlap = match chunk_overlap {\n            Some(value) => {\n                println!(\"Set chunk overlay: {value}\");\n                value\n            }\n            None => {\n                let value = chunk_size / 20;\n                set_chunk_overlay(value)?\n            }\n        };\n\n        Ok((embedding_model, chunk_size, chunk_overlap))\n    }\n\n    pub fn get_config(&self) -> (Option<String>, usize) {\n        (self.data.reranker_model.clone(), self.data.top_k)\n    }\n\n    pub fn get_last_sources(&self) -> Option<String> {\n        self.last_sources.read().clone()\n    }\n\n    pub fn set_last_sources(&self, ids: &[DocumentId]) {\n        let mut sources: IndexMap<String, Vec<String>> = IndexMap::new();\n        for id in ids {\n            let (file_index, _) = id.split();\n            if let Some(file) = self.data.files.get(&file_index) {\n                sources\n                    .entry(file.path.clone())\n                    .or_default()\n                    .push(format!(\"{id:?}\"));\n            }\n        }\n        let sources = if sources.is_empty() {\n            None\n        } else {\n            Some(\n                sources\n                    .into_iter()\n                    .map(|(path, ids)| format!(\"{path} ({})\", ids.join(\",\")))\n                    .collect::<Vec<_>>()\n                    .join(\"\\n\"),\n            )\n        };\n        *self.last_sources.write() = sources;\n    }\n\n    pub fn set_reranker_model(&mut self, reranker_model: Option<String>) -> Result<()> {\n        self.data.reranker_model = reranker_model;\n        self.save()?;\n        Ok(())\n    }\n\n    pub fn set_top_k(&mut self, top_k: usize) -> Result<()> {\n        self.data.top_k = top_k;\n        self.save()?;\n        Ok(())\n    }\n\n    pub fn save(&self) -> Result<bool> {\n        if self.is_temp() {\n            return Ok(false);\n        }\n        let path = Path::new(&self.path);\n        ensure_parent_exists(path)?;\n\n        let content = serde_yaml::to_string(&self.data)\n            .with_context(|| format!(\"Failed to serde rag '{}'\", self.name))?;\n        fs::write(path, content).with_context(|| {\n            format!(\"Failed to save rag '{}' to '{}'\", self.name, path.display())\n        })?;\n\n        Ok(true)\n    }\n\n    pub fn export(&self) -> Result<String> {\n        let files: Vec<_> = self\n            .data\n            .files\n            .iter()\n            .map(|(_, v)| {\n                json!({\n                    \"path\": v.path,\n                    \"num_chunks\": v.documents.len(),\n                })\n            })\n            .collect();\n        let data = json!({\n            \"path\": self.path,\n            \"embedding_model\": self.embedding_model.id(),\n            \"chunk_size\": self.data.chunk_size,\n            \"chunk_overlap\": self.data.chunk_overlap,\n            \"reranker_model\": self.data.reranker_model,\n            \"top_k\": self.data.top_k,\n            \"batch_size\": self.data.batch_size,\n            \"document_paths\": self.data.document_paths,\n            \"files\": files,\n        });\n        let output = serde_yaml::to_string(&data)\n            .with_context(|| format!(\"Unable to show info about rag '{}'\", self.name))?;\n        Ok(output)\n    }\n\n    pub fn name(&self) -> &str {\n        &self.name\n    }\n\n    pub fn is_temp(&self) -> bool {\n        self.name == TEMP_RAG_NAME\n    }\n\n    pub async fn search(\n        &self,\n        text: &str,\n        top_k: usize,\n        rerank_model: Option<&str>,\n        abort_signal: AbortSignal,\n    ) -> Result<(String, Vec<DocumentId>)> {\n        let ret = abortable_run_with_spinner(\n            self.hybird_search(text, top_k, rerank_model),\n            \"Searching\",\n            abort_signal,\n        )\n        .await;\n        let (ids, documents): (Vec<_>, Vec<_>) = ret?.into_iter().unzip();\n        let embeddings = documents.join(\"\\n\\n\");\n        Ok((embeddings, ids))\n    }\n\n    pub async fn sync_documents(\n        &mut self,\n        paths: &[String],\n        refresh: bool,\n        loaders: HashMap<String, String>,\n        spinner: Option<Spinner>,\n    ) -> Result<()> {\n        if let Some(spinner) = &spinner {\n            let _ = spinner.set_message(String::new());\n        }\n        let (document_paths, mut recursive_urls, mut urls, mut protocol_paths, mut local_paths) =\n            resolve_paths(&loaders, paths).await?;\n        let mut to_deleted: IndexMap<String, Vec<FileId>> = Default::default();\n        if refresh {\n            for (file_id, file) in &self.data.files {\n                to_deleted\n                    .entry(file.hash.clone())\n                    .or_default()\n                    .push(*file_id);\n            }\n        } else {\n            let recursive_urls_cloned = recursive_urls.clone();\n            let match_recursive_url = |v: &str| {\n                recursive_urls_cloned\n                    .iter()\n                    .any(|start_url| v.starts_with(start_url))\n            };\n            recursive_urls = recursive_urls\n                .into_iter()\n                .filter(|v| !self.data.document_paths.contains(&format!(\"{v}**\")))\n                .collect();\n            let protocol_paths_cloned = protocol_paths.clone();\n            let match_protocol_path =\n                |v: &str| protocol_paths_cloned.iter().any(|root| v.starts_with(root));\n            protocol_paths = protocol_paths\n                .into_iter()\n                .filter(|v| !self.data.document_paths.contains(v))\n                .collect();\n            for (file_id, file) in &self.data.files {\n                if is_url(&file.path) {\n                    if !urls.swap_remove(&file.path) && !match_recursive_url(&file.path) {\n                        to_deleted\n                            .entry(file.hash.clone())\n                            .or_default()\n                            .push(*file_id);\n                    }\n                } else if is_loader_protocol(&loaders, &file.path) {\n                    if !match_protocol_path(&file.path) {\n                        to_deleted\n                            .entry(file.hash.clone())\n                            .or_default()\n                            .push(*file_id);\n                    }\n                } else if !local_paths.swap_remove(&file.path) {\n                    to_deleted\n                        .entry(file.hash.clone())\n                        .or_default()\n                        .push(*file_id);\n                }\n            }\n        }\n\n        let mut loaded_documents = vec![];\n        let mut has_error = false;\n        let mut index = 0;\n        let total = recursive_urls.len() + urls.len() + protocol_paths.len() + local_paths.len();\n        let handle_error = |error: anyhow::Error, has_error: &mut bool| {\n            println!(\"{}\", warning_text(&format!(\"⚠️ {error}\")));\n            *has_error = true;\n        };\n        for start_url in recursive_urls {\n            index += 1;\n            println!(\"Load {start_url}** [{index}/{total}]\");\n            match load_recursive_url(&loaders, &start_url).await {\n                Ok(v) => loaded_documents.extend(v),\n                Err(err) => handle_error(err, &mut has_error),\n            }\n        }\n        for url in urls {\n            index += 1;\n            println!(\"Load {url} [{index}/{total}]\");\n            match load_url(&loaders, &url).await {\n                Ok(v) => loaded_documents.push(v),\n                Err(err) => handle_error(err, &mut has_error),\n            }\n        }\n        for protocol_path in protocol_paths {\n            index += 1;\n            println!(\"Load {protocol_path} [{index}/{total}]\");\n            match load_protocol_path(&loaders, &protocol_path) {\n                Ok(v) => loaded_documents.extend(v),\n                Err(err) => handle_error(err, &mut has_error),\n            }\n        }\n        for local_path in local_paths {\n            index += 1;\n            println!(\"Load {local_path} [{index}/{total}]\");\n            match load_file(&loaders, &local_path).await {\n                Ok(v) => loaded_documents.push(v),\n                Err(err) => handle_error(err, &mut has_error),\n            }\n        }\n\n        if has_error {\n            let mut aborted = true;\n            if *IS_STDOUT_TERMINAL && total > 0 {\n                let ans = Confirm::new(\"Some documents failed to load. Continue?\")\n                    .with_default(false)\n                    .prompt()?;\n                aborted = !ans;\n            }\n            if aborted {\n                bail!(\"Aborted\");\n            }\n        }\n\n        let mut rag_files = vec![];\n        for LoadedDocument {\n            path,\n            contents,\n            mut metadata,\n        } in loaded_documents\n        {\n            let hash = sha256(&contents);\n            if let Some(file_ids) = to_deleted.get_mut(&hash) {\n                if let Some((i, _)) = file_ids\n                    .iter()\n                    .enumerate()\n                    .find(|(_, v)| self.data.files[*v].path == path)\n                {\n                    if file_ids.len() == 1 {\n                        to_deleted.swap_remove(&hash);\n                    } else {\n                        file_ids.remove(i);\n                    }\n                    continue;\n                }\n            }\n            let extension = metadata\n                .swap_remove(EXTENSION_METADATA)\n                .unwrap_or_else(|| DEFAULT_EXTENSION.into());\n            let separator = get_separators(&extension);\n            let splitter = RecursiveCharacterTextSplitter::new(\n                self.data.chunk_size,\n                self.data.chunk_overlap,\n                &separator,\n            );\n\n            let split_options = SplitterChunkHeaderOptions::default();\n            let document = RagDocument::new(contents);\n            let split_documents = splitter.split_documents(&[document], &split_options);\n            rag_files.push(RagFile {\n                hash: hash.clone(),\n                path,\n                documents: split_documents,\n            });\n        }\n\n        let mut next_file_id = self.data.next_file_id;\n        let mut files = vec![];\n        let mut document_ids = vec![];\n        let mut embeddings = vec![];\n\n        if !rag_files.is_empty() {\n            let mut texts = vec![];\n            for file in rag_files.into_iter() {\n                for (document_index, document) in file.documents.iter().enumerate() {\n                    document_ids.push(DocumentId::new(next_file_id, document_index));\n                    texts.push(document.page_content.clone())\n                }\n                files.push((next_file_id, file));\n                next_file_id += 1;\n            }\n\n            let embeddings_data = EmbeddingsData::new(texts, false);\n            embeddings = self\n                .create_embeddings(embeddings_data, spinner.clone())\n                .await?;\n        }\n\n        let to_delete_file_ids: Vec<_> = to_deleted.values().flatten().copied().collect();\n        self.data.del(to_delete_file_ids);\n        self.data.add(next_file_id, files, document_ids, embeddings);\n        self.data.document_paths = document_paths.into_iter().collect();\n\n        if self.data.files.is_empty() {\n            bail!(\"No RAG files\");\n        }\n\n        progress(&spinner, \"Building store\".into());\n        self.hnsw = self.data.build_hnsw();\n        self.bm25 = self.data.build_bm25();\n\n        Ok(())\n    }\n\n    async fn hybird_search(\n        &self,\n        query: &str,\n        top_k: usize,\n        rerank_model: Option<&str>,\n    ) -> Result<Vec<(DocumentId, String)>> {\n        let (vector_search_results, keyword_search_results) = tokio::join!(\n            self.vector_search(query, top_k, 0.0),\n            self.keyword_search(query, top_k, 0.0),\n        );\n\n        let vector_search_results = vector_search_results?;\n        debug!(\"vector_search_results: {vector_search_results:?}\",);\n        let vector_search_ids: Vec<DocumentId> =\n            vector_search_results.into_iter().map(|(v, _)| v).collect();\n\n        let keyword_search_results = keyword_search_results?;\n        debug!(\"keyword_search_results: {keyword_search_results:?}\",);\n        let keyword_search_ids: Vec<DocumentId> =\n            keyword_search_results.into_iter().map(|(v, _)| v).collect();\n\n        let ids = match rerank_model {\n            Some(model_id) => {\n                let model =\n                    Model::retrieve_model(&self.config.read(), model_id, ModelType::Reranker)?;\n                let client = init_client(&self.config, Some(model))?;\n                let ids: IndexSet<DocumentId> = [vector_search_ids, keyword_search_ids]\n                    .concat()\n                    .into_iter()\n                    .collect();\n                let mut documents = vec![];\n                let mut documents_ids = vec![];\n                for id in ids {\n                    if let Some(document) = self.data.get(id) {\n                        documents_ids.push(id);\n                        documents.push(document.page_content.to_string());\n                    }\n                }\n                let data = RerankData::new(query.to_string(), documents, top_k);\n                let list = client.rerank(&data).await.context(\"Failed to rerank\")?;\n                let ids: Vec<_> = list\n                    .into_iter()\n                    .take(top_k)\n                    .filter_map(|item| documents_ids.get(item.index).cloned())\n                    .collect();\n                debug!(\"rerank_ids: {ids:?}\");\n                ids\n            }\n            None => {\n                let ids = reciprocal_rank_fusion(\n                    vec![vector_search_ids, keyword_search_ids],\n                    vec![1.125, 1.0],\n                    top_k,\n                );\n                debug!(\"rrf_ids: {ids:?}\");\n                ids\n            }\n        };\n        let output = ids\n            .into_iter()\n            .filter_map(|id| {\n                let document = self.data.get(id)?;\n                Some((id, document.page_content.clone()))\n            })\n            .collect();\n        Ok(output)\n    }\n\n    async fn vector_search(\n        &self,\n        query: &str,\n        top_k: usize,\n        min_score: f32,\n    ) -> Result<Vec<(DocumentId, f32)>> {\n        let splitter = RecursiveCharacterTextSplitter::new(\n            self.data.chunk_size,\n            self.data.chunk_overlap,\n            &DEFAULT_SEPARATES,\n        );\n        let texts = splitter.split_text(query);\n        let embeddings_data = EmbeddingsData::new(texts, true);\n        let embeddings = self.create_embeddings(embeddings_data, None).await?;\n        let output = self\n            .hnsw\n            .parallel_search(&embeddings, top_k, 30)\n            .into_iter()\n            .flat_map(|list| {\n                list.into_iter()\n                    .filter_map(|v| {\n                        let score = 1.0 - v.distance;\n                        if score > min_score {\n                            Some((DocumentId(v.d_id), score))\n                        } else {\n                            None\n                        }\n                    })\n                    .collect::<Vec<_>>()\n            })\n            .collect();\n        Ok(output)\n    }\n\n    async fn keyword_search(\n        &self,\n        query: &str,\n        top_k: usize,\n        min_score: f32,\n    ) -> Result<Vec<(DocumentId, f32)>> {\n        let results = self.bm25.search(query, top_k);\n        let output: Vec<(DocumentId, f32)> = results\n            .into_iter()\n            .filter_map(|v| {\n                let score = v.score;\n                if score > min_score {\n                    Some((v.document.id, score))\n                } else {\n                    None\n                }\n            })\n            .collect();\n        Ok(output)\n    }\n\n    async fn create_embeddings(\n        &self,\n        data: EmbeddingsData,\n        spinner: Option<Spinner>,\n    ) -> Result<EmbeddingsOutput> {\n        let embedding_client = init_client(&self.config, Some(self.embedding_model.clone()))?;\n        let EmbeddingsData { texts, query } = data;\n        let batch_size = self\n            .data\n            .batch_size\n            .or_else(|| self.embedding_model.max_batch_size());\n        let batch_size = match self.embedding_model.max_input_tokens() {\n            Some(max_input_tokens) => {\n                let x = max_input_tokens / self.data.chunk_size;\n                match batch_size {\n                    Some(y) => x.min(y),\n                    None => x,\n                }\n            }\n            None => batch_size.unwrap_or(1),\n        };\n        let mut output = vec![];\n        let batch_chunks = texts.chunks(batch_size.max(1));\n        let batch_chunks_len = batch_chunks.len();\n        let retry_limit = env::var(get_env_name(\"embeddings_retry_limit\"))\n            .ok()\n            .and_then(|v| v.parse::<u32>().ok())\n            .unwrap_or(2);\n        for (index, texts) in batch_chunks.enumerate() {\n            progress(\n                &spinner,\n                format!(\"Creating embeddings [{}/{batch_chunks_len}]\", index + 1),\n            );\n            let chunk_data = EmbeddingsData {\n                texts: texts.to_vec(),\n                query,\n            };\n            let mut retry = 0;\n            let chunk_output = loop {\n                retry += 1;\n                match embedding_client.embeddings(&chunk_data).await {\n                    Ok(v) => break v,\n                    Err(e) if retry < retry_limit => {\n                        debug!(\"retry {retry} failed: {e}\");\n                        sleep(Duration::from_secs(2u64.pow(retry - 1))).await;\n                        continue;\n                    }\n                    Err(e) => {\n                        return Err(e).with_context(|| {\n                            format!(\"Failed to create embedding after {retry_limit} attempts\")\n                        })?\n                    }\n                }\n            };\n            output.extend(chunk_output);\n        }\n        Ok(output)\n    }\n}\n\n#[derive(Clone, Serialize, Deserialize)]\npub struct RagData {\n    pub embedding_model: String,\n    pub chunk_size: usize,\n    pub chunk_overlap: usize,\n    pub reranker_model: Option<String>,\n    pub top_k: usize,\n    pub batch_size: Option<usize>,\n    pub next_file_id: FileId,\n    pub document_paths: Vec<String>,\n    pub files: IndexMap<FileId, RagFile>,\n    #[serde(with = \"serde_vectors\")]\n    pub vectors: IndexMap<DocumentId, Vec<f32>>,\n}\n\nimpl Debug for RagData {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        f.debug_struct(\"RagData\")\n            .field(\"embedding_model\", &self.embedding_model)\n            .field(\"chunk_size\", &self.chunk_size)\n            .field(\"chunk_overlap\", &self.chunk_overlap)\n            .field(\"reranker_model\", &self.reranker_model)\n            .field(\"top_k\", &self.top_k)\n            .field(\"batch_size\", &self.batch_size)\n            .field(\"next_file_id\", &self.next_file_id)\n            .field(\"document_paths\", &self.document_paths)\n            .field(\"files\", &self.files)\n            .finish()\n    }\n}\n\nimpl RagData {\n    pub fn new(\n        embedding_model: String,\n        chunk_size: usize,\n        chunk_overlap: usize,\n        reranker_model: Option<String>,\n        top_k: usize,\n        batch_size: Option<usize>,\n    ) -> Self {\n        Self {\n            embedding_model,\n            chunk_size,\n            chunk_overlap,\n            reranker_model,\n            top_k,\n            batch_size,\n            next_file_id: 0,\n            document_paths: Default::default(),\n            files: Default::default(),\n            vectors: Default::default(),\n        }\n    }\n\n    pub fn get(&self, id: DocumentId) -> Option<&RagDocument> {\n        let (file_index, document_index) = id.split();\n        let file = self.files.get(&file_index)?;\n        let document = file.documents.get(document_index)?;\n        Some(document)\n    }\n\n    pub fn del(&mut self, file_ids: Vec<FileId>) {\n        for file_id in file_ids {\n            if let Some(file) = self.files.swap_remove(&file_id) {\n                for (document_index, _) in file.documents.iter().enumerate() {\n                    let document_id = DocumentId::new(file_id, document_index);\n                    self.vectors.swap_remove(&document_id);\n                }\n            }\n        }\n    }\n\n    pub fn add(\n        &mut self,\n        next_file_id: FileId,\n        files: Vec<(FileId, RagFile)>,\n        document_ids: Vec<DocumentId>,\n        embeddings: EmbeddingsOutput,\n    ) {\n        self.next_file_id = next_file_id;\n        self.files.extend(files);\n        self.vectors\n            .extend(document_ids.into_iter().zip(embeddings));\n    }\n\n    pub fn build_hnsw(&self) -> Hnsw<'static, f32, DistCosine> {\n        let hnsw = Hnsw::new(32, self.vectors.len(), 16, 200, DistCosine {});\n        let list: Vec<_> = self.vectors.iter().map(|(k, v)| (v, k.0)).collect();\n        hnsw.parallel_insert(&list);\n        hnsw\n    }\n\n    pub fn build_bm25(&self) -> SearchEngine<DocumentId> {\n        let mut documents = vec![];\n        for (file_index, file) in self.files.iter() {\n            for (document_index, document) in file.documents.iter().enumerate() {\n                let id = DocumentId::new(*file_index, document_index);\n                documents.push(bm25::Document::new(id, &document.page_content))\n            }\n        }\n        SearchEngineBuilder::<DocumentId>::with_documents(Language::English, documents)\n            .k1(1.5)\n            .b(0.75)\n            .build()\n    }\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct RagFile {\n    hash: String,\n    path: String,\n    documents: Vec<RagDocument>,\n}\n\n#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]\npub struct RagDocument {\n    pub page_content: String,\n    pub metadata: DocumentMetadata,\n}\n\nimpl RagDocument {\n    pub fn new<S: Into<String>>(page_content: S) -> Self {\n        RagDocument {\n            page_content: page_content.into(),\n            metadata: IndexMap::new(),\n        }\n    }\n}\n\nimpl Default for RagDocument {\n    fn default() -> Self {\n        RagDocument {\n            page_content: \"\".to_string(),\n            metadata: IndexMap::new(),\n        }\n    }\n}\n\npub type FileId = usize;\n\n#[derive(Clone, Copy, Hash, Eq, PartialEq, Ord, PartialOrd)]\npub struct DocumentId(usize);\n\nimpl Debug for DocumentId {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        let (file_index, document_index) = self.split();\n        f.write_fmt(format_args!(\"{file_index}-{document_index}\"))\n    }\n}\n\nimpl DocumentId {\n    pub fn new(file_index: usize, document_index: usize) -> Self {\n        let value = (file_index << (usize::BITS / 2)) | document_index;\n        Self(value)\n    }\n\n    pub fn split(self) -> (usize, usize) {\n        let value = self.0;\n        let low_mask = (1 << (usize::BITS / 2)) - 1;\n        let low = value & low_mask;\n        let high = value >> (usize::BITS / 2);\n        (high, low)\n    }\n}\n\nfn select_embedding_model(models: &[&Model]) -> Result<String> {\n    let models: Vec<_> = models\n        .iter()\n        .map(|v| SelectOption::new(v.id(), v.description()))\n        .collect();\n    let result = Select::new(\"Select embedding model:\", models).prompt()?;\n    Ok(result.value)\n}\n\n#[derive(Debug)]\nstruct SelectOption {\n    pub value: String,\n    pub description: String,\n}\n\nimpl SelectOption {\n    pub fn new(value: String, description: String) -> Self {\n        Self { value, description }\n    }\n}\n\nimpl std::fmt::Display for SelectOption {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        write!(f, \"{} ({})\", self.value, self.description)\n    }\n}\n\nfn set_chunk_size(model: &Model) -> Result<usize> {\n    let default_value = model.default_chunk_size().to_string();\n    let help_message = model\n        .max_tokens_per_chunk()\n        .map(|v| format!(\"The model's max_tokens is {v}\"));\n\n    let mut text = Text::new(\"Set chunk size:\")\n        .with_default(&default_value)\n        .with_validator(move |text: &str| {\n            let out = match text.parse::<usize>() {\n                Ok(_) => Validation::Valid,\n                Err(_) => Validation::Invalid(\"Must be a integer\".into()),\n            };\n            Ok(out)\n        });\n    if let Some(help_message) = &help_message {\n        text = text.with_help_message(help_message);\n    }\n    let value = text.prompt()?;\n    value.parse().map_err(|_| anyhow!(\"Invalid chunk_size\"))\n}\n\nfn set_chunk_overlay(default_value: usize) -> Result<usize> {\n    let value = Text::new(\"Set chunk overlay:\")\n        .with_default(&default_value.to_string())\n        .with_validator(move |text: &str| {\n            let out = match text.parse::<usize>() {\n                Ok(_) => Validation::Valid,\n                Err(_) => Validation::Invalid(\"Must be a integer\".into()),\n            };\n            Ok(out)\n        })\n        .prompt()?;\n    value.parse().map_err(|_| anyhow!(\"Invalid chunk_overlay\"))\n}\n\nfn add_documents() -> Result<Vec<String>> {\n    let text = Text::new(\"Add documents:\")\n        .with_validator(required!(\"This field is required\"))\n        .with_help_message(\"e.g. file;dir/;dir/**/*.{md,mdx};loader:resource;url;website/**\")\n        .prompt()?;\n    let paths = text\n        .split(';')\n        .filter_map(|v| {\n            let v = v.trim().to_string();\n            if v.is_empty() {\n                None\n            } else {\n                Some(v)\n            }\n        })\n        .collect();\n    Ok(paths)\n}\n\nasync fn resolve_paths<T: AsRef<str>>(\n    loaders: &HashMap<String, String>,\n    paths: &[T],\n) -> Result<(\n    IndexSet<String>,\n    IndexSet<String>,\n    IndexSet<String>,\n    IndexSet<String>,\n    IndexSet<String>,\n)> {\n    let mut document_paths = IndexSet::new();\n    let mut recursive_urls = IndexSet::new();\n    let mut urls = IndexSet::new();\n    let mut protocol_paths = IndexSet::new();\n    let mut absolute_paths = vec![];\n    for path in paths {\n        let path = path.as_ref().trim();\n        if is_url(path) {\n            if let Some(start_url) = path.strip_suffix(\"**\") {\n                recursive_urls.insert(start_url.to_string());\n            } else {\n                urls.insert(path.to_string());\n            }\n            document_paths.insert(path.to_string());\n        } else if is_loader_protocol(loaders, path) {\n            protocol_paths.insert(path.to_string());\n            document_paths.insert(path.to_string());\n        } else {\n            let resolved_path = resolve_home_dir(path);\n            let absolute_path = to_absolute_path(&resolved_path)\n                .with_context(|| format!(\"Invalid path '{path}'\"))?;\n            absolute_paths.push(resolved_path);\n            document_paths.insert(absolute_path);\n        }\n    }\n    let local_paths = expand_glob_paths(&absolute_paths, false).await?;\n    Ok((\n        document_paths,\n        recursive_urls,\n        urls,\n        protocol_paths,\n        local_paths,\n    ))\n}\n\nfn progress(spinner: &Option<Spinner>, message: String) {\n    if let Some(spinner) = spinner {\n        let _ = spinner.set_message(message);\n    }\n}\n\nfn reciprocal_rank_fusion(\n    list_of_document_ids: Vec<Vec<DocumentId>>,\n    list_of_weights: Vec<f32>,\n    top_k: usize,\n) -> Vec<DocumentId> {\n    let rrf_k = top_k * 2;\n    let mut map: IndexMap<DocumentId, f32> = IndexMap::new();\n    for (document_ids, weight) in list_of_document_ids\n        .into_iter()\n        .zip(list_of_weights.into_iter())\n    {\n        for (index, &item) in document_ids.iter().enumerate() {\n            *map.entry(item).or_default() += (1.0 / ((rrf_k + index + 1) as f32)) * weight;\n        }\n    }\n    let mut sorted_items: Vec<(DocumentId, f32)> = map.into_iter().collect();\n    sorted_items.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());\n\n    sorted_items\n        .into_iter()\n        .take(top_k)\n        .map(|(v, _)| v)\n        .collect()\n}\n"
  },
  {
    "path": "src/rag/serde_vectors.rs",
    "content": "use super::*;\n\nuse base64::{engine::general_purpose::STANDARD, Engine};\nuse serde::{de, Deserializer, Serializer};\n\npub fn serialize<S>(\n    vectors: &IndexMap<DocumentId, Vec<f32>>,\n    serializer: S,\n) -> Result<S::Ok, S::Error>\nwhere\n    S: Serializer,\n{\n    let encoded_map: IndexMap<String, String> = vectors\n        .iter()\n        .map(|(id, vec)| {\n            let (h, l) = id.split();\n            let byte_slice = unsafe {\n                std::slice::from_raw_parts(\n                    vec.as_ptr() as *const u8,\n                    vec.len() * std::mem::size_of::<f32>(),\n                )\n            };\n            (format!(\"{h}-{l}\"), STANDARD.encode(byte_slice))\n        })\n        .collect();\n\n    encoded_map.serialize(serializer)\n}\n\npub fn deserialize<'de, D>(deserializer: D) -> Result<IndexMap<DocumentId, Vec<f32>>, D::Error>\nwhere\n    D: Deserializer<'de>,\n{\n    let encoded_map: IndexMap<String, String> =\n        IndexMap::<String, String>::deserialize(deserializer)?;\n\n    let mut decoded_map = IndexMap::new();\n    for (key, base64_str) in encoded_map {\n        let decoded_key: DocumentId = key\n            .split_once('-')\n            .and_then(|(h, l)| {\n                let h = h.parse::<usize>().ok()?;\n                let l = l.parse::<usize>().ok()?;\n                Some(DocumentId::new(h, l))\n            })\n            .ok_or_else(|| de::Error::custom(format!(\"Invalid key '{key}'\")))?;\n\n        let decoded_data = STANDARD.decode(&base64_str).map_err(de::Error::custom)?;\n\n        if decoded_data.len() % std::mem::size_of::<f32>() != 0 {\n            return Err(de::Error::custom(format!(\"Invalid vector at '{key}'\")));\n        }\n\n        let num_f32s = decoded_data.len() / std::mem::size_of::<f32>();\n\n        let mut vec_f32 = vec![0.0f32; num_f32s];\n        unsafe {\n            std::ptr::copy_nonoverlapping(\n                decoded_data.as_ptr(),\n                vec_f32.as_mut_ptr() as *mut u8,\n                decoded_data.len(),\n            );\n        }\n\n        decoded_map.insert(decoded_key, vec_f32);\n    }\n\n    Ok(decoded_map)\n}\n"
  },
  {
    "path": "src/rag/splitter/language.rs",
    "content": "#[derive(PartialEq, Eq, Hash)]\npub enum Language {\n    Cpp,\n    Go,\n    Java,\n    Js,\n    Php,\n    Proto,\n    Python,\n    Rst,\n    Ruby,\n    Rust,\n    Scala,\n    Swift,\n    Markdown,\n    Latex,\n    Html,\n    Sol,\n}\n\nimpl Language {\n    pub fn separators(&self) -> Vec<&str> {\n        match self {\n            Language::Cpp => vec![\n                \"\\nclass \",\n                \"\\nvoid \",\n                \"\\nint \",\n                \"\\nfloat \",\n                \"\\ndouble \",\n                \"\\nif \",\n                \"\\nfor \",\n                \"\\nwhile \",\n                \"\\nswitch \",\n                \"\\ncase \",\n                \"\\n\\n\",\n                \"\\n\",\n                \" \",\n                \"\",\n            ],\n            Language::Go => vec![\n                \"\\nfunc \",\n                \"\\nvar \",\n                \"\\nconst \",\n                \"\\ntype \",\n                \"\\nif \",\n                \"\\nfor \",\n                \"\\nswitch \",\n                \"\\ncase \",\n                \"\\n\\n\",\n                \"\\n\",\n                \" \",\n                \"\",\n            ],\n            Language::Java => vec![\n                \"\\nclass \",\n                \"\\npublic \",\n                \"\\nprotected \",\n                \"\\nprivate \",\n                \"\\nstatic \",\n                \"\\nif \",\n                \"\\nfor \",\n                \"\\nwhile \",\n                \"\\nswitch \",\n                \"\\ncase \",\n                \"\\n\\n\",\n                \"\\n\",\n                \" \",\n                \"\",\n            ],\n            Language::Js => vec![\n                \"\\nfunction \",\n                \"\\nconst \",\n                \"\\nlet \",\n                \"\\nvar \",\n                \"\\nclass \",\n                \"\\nif \",\n                \"\\nfor \",\n                \"\\nwhile \",\n                \"\\nswitch \",\n                \"\\ncase \",\n                \"\\ndefault \",\n                \"\\n\\n\",\n                \"\\n\",\n                \" \",\n                \"\",\n            ],\n            Language::Php => vec![\n                \"\\nfunction \",\n                \"\\nclass \",\n                \"\\nif \",\n                \"\\nforeach \",\n                \"\\nwhile \",\n                \"\\ndo \",\n                \"\\nswitch \",\n                \"\\ncase \",\n                \"\\n\\n\",\n                \"\\n\",\n                \" \",\n                \"\",\n            ],\n            Language::Proto => vec![\n                \"\\nmessage \",\n                \"\\nservice \",\n                \"\\nenum \",\n                \"\\noption \",\n                \"\\nimport \",\n                \"\\nsyntax \",\n                \"\\n\\n\",\n                \"\\n\",\n                \" \",\n                \"\",\n            ],\n            Language::Python => vec![\"\\nclass \", \"\\ndef \", \"\\n\\tdef \", \"\\n\\n\", \"\\n\", \" \", \"\"],\n            Language::Rst => vec![\n                \"\\n===\\n\", \"\\n---\\n\", \"\\n***\\n\", \"\\n.. \", \"\\n\\n\", \"\\n\", \" \", \"\",\n            ],\n            Language::Ruby => vec![\n                \"\\ndef \",\n                \"\\nclass \",\n                \"\\nif \",\n                \"\\nunless \",\n                \"\\nwhile \",\n                \"\\nfor \",\n                \"\\ndo \",\n                \"\\nbegin \",\n                \"\\nrescue \",\n                \"\\n\\n\",\n                \"\\n\",\n                \" \",\n                \"\",\n            ],\n            Language::Rust => vec![\n                \"\\nfn \", \"\\nconst \", \"\\nlet \", \"\\nif \", \"\\nwhile \", \"\\nfor \", \"\\nloop \",\n                \"\\nmatch \", \"\\nconst \", \"\\n\\n\", \"\\n\", \" \", \"\",\n            ],\n            Language::Scala => vec![\n                \"\\nclass \",\n                \"\\nobject \",\n                \"\\ndef \",\n                \"\\nval \",\n                \"\\nvar \",\n                \"\\nif \",\n                \"\\nfor \",\n                \"\\nwhile \",\n                \"\\nmatch \",\n                \"\\ncase \",\n                \"\\n\\n\",\n                \"\\n\",\n                \" \",\n                \"\",\n            ],\n            Language::Swift => vec![\n                \"\\nfunc \",\n                \"\\nclass \",\n                \"\\nstruct \",\n                \"\\nenum \",\n                \"\\nif \",\n                \"\\nfor \",\n                \"\\nwhile \",\n                \"\\ndo \",\n                \"\\nswitch \",\n                \"\\ncase \",\n                \"\\n\\n\",\n                \"\\n\",\n                \" \",\n                \"\",\n            ],\n            Language::Markdown => vec![\n                \"\\n## \",\n                \"\\n### \",\n                \"\\n#### \",\n                \"\\n##### \",\n                \"\\n###### \",\n                \"```\\n\\n\",\n                \"\\n\\n***\\n\\n\",\n                \"\\n\\n---\\n\\n\",\n                \"\\n\\n___\\n\\n\",\n                \"\\n\\n\",\n                \"\\n\",\n                \" \",\n                \"\",\n            ],\n            Language::Latex => vec![\n                \"\\n\\\\chapter{\",\n                \"\\n\\\\section{\",\n                \"\\n\\\\subsection{\",\n                \"\\n\\\\subsubsection{\",\n                \"\\n\\\\begin{enumerate}\",\n                \"\\n\\\\begin{itemize}\",\n                \"\\n\\\\begin{description}\",\n                \"\\n\\\\begin{list}\",\n                \"\\n\\\\begin{quote}\",\n                \"\\n\\\\begin{quotation}\",\n                \"\\n\\\\begin{verse}\",\n                \"\\n\\\\begin{verbatim}\",\n                \"\\n\\\\begin{align}\",\n                \"$$\",\n                \"$\",\n                \"\\n\\n\",\n                \"\\n\",\n                \" \",\n                \"\",\n            ],\n            Language::Html => vec![\n                \"<body>\", \"<div>\", \"<p>\", \"<br>\", \"<li>\", \"<h1>\", \"<h2>\", \"<h3>\", \"<h4>\", \"<h5>\",\n                \"<h6>\", \"<span>\", \"<table>\", \"<tr>\", \"<td>\", \"<th>\", \"<ul>\", \"<ol>\", \"<header>\",\n                \"<footer>\", \"<nav>\", \"<head>\", \"<style>\", \"<script>\", \"<meta>\", \"<title>\", \" \", \"\",\n            ],\n            Language::Sol => vec![\n                \"\\npragma \",\n                \"\\nusing \",\n                \"\\ncontract \",\n                \"\\ninterface \",\n                \"\\nlibrary \",\n                \"\\nconstructor \",\n                \"\\ntype \",\n                \"\\nfunction \",\n                \"\\nevent \",\n                \"\\nmodifier \",\n                \"\\nerror \",\n                \"\\nstruct \",\n                \"\\nenum \",\n                \"\\nif \",\n                \"\\nfor \",\n                \"\\nwhile \",\n                \"\\ndo while \",\n                \"\\nassembly \",\n                \"\\n\\n\",\n                \"\\n\",\n                \" \",\n                \"\",\n            ],\n        }\n    }\n}\n"
  },
  {
    "path": "src/rag/splitter/mod.rs",
    "content": "mod language;\n\npub use self::language::*;\n\nuse super::{DocumentMetadata, RagDocument};\n\npub const DEFAULT_SEPARATES: [&str; 4] = [\"\\n\\n\", \"\\n\", \" \", \"\"];\n\npub fn get_separators(extension: &str) -> Vec<&'static str> {\n    match extension {\n        \"c\" | \"cc\" | \"cpp\" => Language::Cpp.separators(),\n        \"go\" => Language::Go.separators(),\n        \"java\" => Language::Java.separators(),\n        \"js\" | \"mjs\" | \"cjs\" => Language::Js.separators(),\n        \"php\" => Language::Php.separators(),\n        \"proto\" => Language::Proto.separators(),\n        \"py\" => Language::Python.separators(),\n        \"rst\" => Language::Rst.separators(),\n        \"rb\" => Language::Ruby.separators(),\n        \"rs\" => Language::Rust.separators(),\n        \"scala\" => Language::Scala.separators(),\n        \"swift\" => Language::Swift.separators(),\n        \"md\" | \"mkd\" => Language::Markdown.separators(),\n        \"tex\" => Language::Latex.separators(),\n        \"htm\" | \"html\" => Language::Html.separators(),\n        \"sol\" => Language::Sol.separators(),\n        _ => DEFAULT_SEPARATES.to_vec(),\n    }\n}\n\npub struct RecursiveCharacterTextSplitter {\n    pub chunk_size: usize,\n    pub chunk_overlap: usize,\n    pub separators: Vec<String>,\n    pub length_function: Box<dyn Fn(&str) -> usize + Send + Sync>,\n}\n\nimpl Default for RecursiveCharacterTextSplitter {\n    fn default() -> Self {\n        Self {\n            chunk_size: 1000,\n            chunk_overlap: 20,\n            separators: DEFAULT_SEPARATES.iter().map(|v| v.to_string()).collect(),\n            length_function: Box::new(|text| text.len()),\n        }\n    }\n}\n\nimpl RecursiveCharacterTextSplitter {\n    pub fn new(chunk_size: usize, chunk_overlap: usize, separators: &[&str]) -> Self {\n        Self::default()\n            .with_chunk_size(chunk_size)\n            .with_chunk_overlap(chunk_overlap)\n            .with_separators(separators)\n    }\n\n    pub fn with_chunk_size(mut self, chunk_size: usize) -> Self {\n        self.chunk_size = chunk_size;\n        self\n    }\n\n    pub fn with_chunk_overlap(mut self, chunk_overlap: usize) -> Self {\n        self.chunk_overlap = chunk_overlap;\n        self\n    }\n\n    pub fn with_separators(mut self, separators: &[&str]) -> Self {\n        self.separators = separators.iter().map(|v| v.to_string()).collect();\n        self\n    }\n\n    pub fn split_documents(\n        &self,\n        documents: &[RagDocument],\n        chunk_header_options: &SplitterChunkHeaderOptions,\n    ) -> Vec<RagDocument> {\n        let mut texts: Vec<String> = Vec::new();\n        let mut metadatas: Vec<DocumentMetadata> = Vec::new();\n        documents.iter().for_each(|d| {\n            if !d.page_content.is_empty() {\n                texts.push(d.page_content.clone());\n                metadatas.push(d.metadata.clone());\n            }\n        });\n\n        self.create_documents(&texts, &metadatas, chunk_header_options)\n    }\n\n    pub fn create_documents(\n        &self,\n        texts: &[String],\n        metadatas: &[DocumentMetadata],\n        chunk_header_options: &SplitterChunkHeaderOptions,\n    ) -> Vec<RagDocument> {\n        let SplitterChunkHeaderOptions {\n            chunk_header,\n            chunk_overlap_header,\n        } = chunk_header_options;\n\n        let mut documents = Vec::new();\n        for (i, text) in texts.iter().enumerate() {\n            let mut prev_chunk: Option<String> = None;\n            let mut index_prev_chunk = -1;\n\n            for chunk in self.split_text(text) {\n                let mut page_content = chunk_header.clone();\n\n                let index_chunk = if index_prev_chunk < 0 {\n                    text.find(&chunk).map(|i| i as i32).unwrap_or(-1)\n                } else {\n                    match text[(index_prev_chunk as usize)..].chars().next() {\n                        Some(c) => {\n                            let offset = (index_prev_chunk as usize) + c.len_utf8();\n                            text[offset..]\n                                .find(&chunk)\n                                .map(|i| (i + offset) as i32)\n                                .unwrap_or(-1)\n                        }\n                        None => -1,\n                    }\n                };\n\n                if prev_chunk.is_some() {\n                    if let Some(chunk_overlap_header) = chunk_overlap_header {\n                        page_content += chunk_overlap_header;\n                    }\n                }\n\n                let metadata = metadatas[i].clone();\n                page_content += &chunk;\n                documents.push(RagDocument {\n                    page_content,\n                    metadata,\n                });\n\n                prev_chunk = Some(chunk);\n                index_prev_chunk = index_chunk;\n            }\n        }\n\n        documents\n    }\n\n    pub fn split_text(&self, text: &str) -> Vec<String> {\n        let keep_separator = self\n            .separators\n            .iter()\n            .any(|v| v.chars().any(|v| !v.is_whitespace()));\n        self.split_text_impl(text, &self.separators, keep_separator)\n    }\n\n    fn split_text_impl(\n        &self,\n        text: &str,\n        separators: &[String],\n        keep_separator: bool,\n    ) -> Vec<String> {\n        let mut final_chunks = Vec::new();\n\n        let mut separator: String = separators.last().cloned().unwrap_or_default();\n        let mut new_separators: Vec<String> = vec![];\n        for (i, s) in separators.iter().enumerate() {\n            if s.is_empty() {\n                separator.clone_from(s);\n                break;\n            }\n            if text.contains(s) {\n                separator.clone_from(s);\n                new_separators = separators[i + 1..].to_vec();\n                break;\n            }\n        }\n\n        // Now that we have the separator, split the text\n        let splits = split_on_separator(text, &separator, keep_separator);\n\n        // Now go merging things, recursively splitting longer texts.\n        let mut good_splits = Vec::new();\n        let _separator = if keep_separator { \"\" } else { &separator };\n        for s in splits {\n            if (self.length_function)(s) < self.chunk_size {\n                good_splits.push(s.to_string());\n            } else {\n                if !good_splits.is_empty() {\n                    let merged_text = self.merge_splits(&good_splits, _separator);\n                    final_chunks.extend(merged_text);\n                    good_splits.clear();\n                }\n                if new_separators.is_empty() {\n                    final_chunks.push(s.to_string());\n                } else {\n                    let other_info = self.split_text_impl(s, &new_separators, keep_separator);\n                    final_chunks.extend(other_info);\n                }\n            }\n        }\n        if !good_splits.is_empty() {\n            let merged_text = self.merge_splits(&good_splits, _separator);\n            final_chunks.extend(merged_text);\n        }\n        final_chunks\n    }\n\n    fn merge_splits(&self, splits: &[String], separator: &str) -> Vec<String> {\n        let mut docs = Vec::new();\n        let mut current_doc = Vec::new();\n        let mut total = 0;\n        for d in splits {\n            let _len = (self.length_function)(d);\n            if total + _len + current_doc.len() * separator.len() > self.chunk_size {\n                if total > self.chunk_size {\n                    // warn!(\"Warning: Created a chunk of size {}, which is longer than the specified {}\", total, self.chunk_size);\n                }\n                if !current_doc.is_empty() {\n                    let doc = self.join_docs(&current_doc, separator);\n                    if let Some(doc) = doc {\n                        docs.push(doc);\n                    }\n                    // Keep on popping if:\n                    // - we have a larger chunk than in the chunk overlap\n                    // - or if we still have any chunks and the length is long\n                    while total > self.chunk_overlap\n                        || (total + _len + current_doc.len() * separator.len() > self.chunk_size\n                            && total > 0)\n                    {\n                        total -= (self.length_function)(&current_doc[0]);\n                        current_doc.remove(0);\n                    }\n                }\n            }\n            current_doc.push(d.to_string());\n            total += _len;\n        }\n        let doc = self.join_docs(&current_doc, separator);\n        if let Some(doc) = doc {\n            docs.push(doc);\n        }\n        docs\n    }\n\n    fn join_docs(&self, docs: &[String], separator: &str) -> Option<String> {\n        let text = docs.join(separator).trim().to_string();\n        if text.is_empty() {\n            None\n        } else {\n            Some(text)\n        }\n    }\n}\n\npub struct SplitterChunkHeaderOptions {\n    pub chunk_header: String,\n    pub chunk_overlap_header: Option<String>,\n}\n\nimpl Default for SplitterChunkHeaderOptions {\n    fn default() -> Self {\n        Self {\n            chunk_header: \"\".into(),\n            chunk_overlap_header: None,\n        }\n    }\n}\n\nimpl SplitterChunkHeaderOptions {\n    // Set the value of chunk_header\n    #[allow(unused)]\n    pub fn with_chunk_header(mut self, header: &str) -> Self {\n        self.chunk_header = header.to_string();\n        self\n    }\n\n    // Set the value of chunk_overlap_header\n    #[allow(unused)]\n    pub fn with_chunk_overlap_header(mut self, overlap_header: &str) -> Self {\n        self.chunk_overlap_header = Some(overlap_header.to_string());\n        self\n    }\n}\n\nfn split_on_separator<'a>(text: &'a str, separator: &str, keep_separator: bool) -> Vec<&'a str> {\n    let splits: Vec<&str> = if !separator.is_empty() {\n        if keep_separator {\n            let mut splits = Vec::new();\n            let mut prev_idx = 0;\n            let sep_len = separator.len();\n\n            while let Some(idx) = text[prev_idx..].find(separator) {\n                splits.push(&text[prev_idx.saturating_sub(sep_len)..prev_idx + idx]);\n                prev_idx += idx + sep_len;\n            }\n\n            if prev_idx < text.len() {\n                splits.push(&text[prev_idx.saturating_sub(sep_len)..]);\n            }\n\n            splits\n        } else {\n            text.split(separator).collect()\n        }\n    } else {\n        text.split(\"\").collect()\n    };\n    splits.into_iter().filter(|s| !s.is_empty()).collect()\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use indexmap::IndexMap;\n    use pretty_assertions::assert_eq;\n    use serde_json::{json, Value};\n\n    fn build_metadata(source: &str) -> Value {\n        json!({ \"source\": source })\n    }\n    #[test]\n    fn test_split_text() {\n        let splitter = RecursiveCharacterTextSplitter {\n            chunk_size: 7,\n            chunk_overlap: 3,\n            separators: vec![\" \".into()],\n            ..Default::default()\n        };\n        let output = splitter.split_text(\"foo bar baz 123\");\n        assert_eq!(output, vec![\"foo bar\", \"bar baz\", \"baz 123\"]);\n    }\n\n    #[test]\n    fn test_create_document() {\n        let splitter = RecursiveCharacterTextSplitter::new(3, 0, &[\" \"]);\n        let chunk_header_options = SplitterChunkHeaderOptions::default();\n        let mut metadata1 = IndexMap::new();\n        metadata1.insert(\"source\".into(), \"1\".into());\n        let mut metadata2 = IndexMap::new();\n        metadata2.insert(\"source\".into(), \"2\".into());\n        let output = splitter.create_documents(\n            &[\"foo bar\".into(), \"baz\".into()],\n            &[metadata1, metadata2],\n            &chunk_header_options,\n        );\n        let output = json!(output);\n        assert_eq!(\n            output,\n            json!([\n                {\n                    \"page_content\": \"foo\",\n                    \"metadata\": build_metadata(\"1\"),\n                },\n                {\n                    \"page_content\": \"bar\",\n                    \"metadata\": build_metadata(\"1\"),\n                },\n                {\n                    \"page_content\": \"baz\",\n                    \"metadata\": build_metadata(\"2\"),\n                },\n            ])\n        );\n    }\n\n    #[test]\n    fn test_chunk_header() {\n        let splitter = RecursiveCharacterTextSplitter::new(3, 0, &[\" \"]);\n        let chunk_header_options = SplitterChunkHeaderOptions::default()\n            .with_chunk_header(\"SOURCE NAME: testing\\n-----\\n\")\n            .with_chunk_overlap_header(\"(cont'd) \");\n        let mut metadata1 = IndexMap::new();\n        metadata1.insert(\"source\".into(), \"1\".into());\n        let mut metadata2 = IndexMap::new();\n        metadata2.insert(\"source\".into(), \"2\".into());\n        let output = splitter.create_documents(\n            &[\"foo bar\".into(), \"baz\".into()],\n            &[metadata1, metadata2],\n            &chunk_header_options,\n        );\n        let output = json!(output);\n        assert_eq!(\n            output,\n            json!([\n                {\n                    \"page_content\": \"SOURCE NAME: testing\\n-----\\nfoo\",\n                    \"metadata\": build_metadata(\"1\"),\n                },\n                {\n                    \"page_content\": \"SOURCE NAME: testing\\n-----\\n(cont'd) bar\",\n                    \"metadata\": build_metadata(\"1\"),\n                },\n                {\n                    \"page_content\": \"SOURCE NAME: testing\\n-----\\nbaz\",\n                    \"metadata\": build_metadata(\"2\"),\n                },\n            ])\n        );\n    }\n\n    #[test]\n    fn test_markdown_splitter() {\n        let text = r#\"# 🦜️🔗 LangChain\n\n⚡ Building applications with LLMs through composability ⚡\n\n## Quick Install\n\n```bash\n# Hopefully this code block isn't split\npip install langchain\n```\n\nAs an open source project in a rapidly developing field, we are extremely open to contributions.\"#;\n        let splitter =\n            RecursiveCharacterTextSplitter::new(100, 0, &Language::Markdown.separators());\n        let output = splitter.split_text(text);\n        let expected_output = vec![\n            \"# 🦜️🔗 LangChain\\n\\n⚡ Building applications with LLMs through composability ⚡\",\n            \"## Quick Install\\n\\n```bash\\n# Hopefully this code block isn't split\\npip install langchain\",\n            \"```\",\n            \"As an open source project in a rapidly developing field, we are extremely open to contributions.\",\n        ];\n        assert_eq!(output, expected_output);\n    }\n\n    #[test]\n    fn test_html_splitter() {\n        let text = r#\"<!DOCTYPE html>\n<html>\n  <head>\n    <title>🦜️🔗 LangChain</title>\n    <style>\n      body {\n        font-family: Arial, sans-serif;\n      }\n      h1 {\n        color: darkblue;\n      }\n    </style>\n  </head>\n  <body>\n    <div>\n      <h1>🦜️🔗 LangChain</h1>\n      <p>⚡ Building applications with LLMs through composability ⚡</p>\n    </div>\n    <div>\n      As an open source project in a rapidly developing field, we are extremely open to contributions.\n    </div>\n  </body>\n</html>\"#;\n        let splitter = RecursiveCharacterTextSplitter::new(175, 20, &Language::Html.separators());\n        let output = splitter.split_text(text);\n        let expected_output = vec![\n            \"<!DOCTYPE html>\\n<html>\",\n            \"<head>\\n    <title>🦜️🔗 LangChain</title>\",\n            r#\"<style>\n      body {\n        font-family: Arial, sans-serif;\n      }\n      h1 {\n        color: darkblue;\n      }\n    </style>\n  </head>\"#,\n            r#\"<body>\n    <div>\n      <h1>🦜️🔗 LangChain</h1>\n      <p>⚡ Building applications with LLMs through composability ⚡</p>\n    </div>\"#,\n            r#\"<div>\n      As an open source project in a rapidly developing field, we are extremely open to contributions.\n    </div>\n  </body>\n</html>\"#,\n        ];\n        assert_eq!(output, expected_output);\n    }\n}\n"
  },
  {
    "path": "src/render/markdown.rs",
    "content": "use crate::utils::decode_bin;\n\nuse ansi_colours::AsRGB;\nuse anyhow::{anyhow, Context, Result};\nuse crossterm::style::{Color, Stylize};\nuse crossterm::terminal;\nuse std::collections::HashMap;\nuse std::sync::LazyLock;\nuse syntect::highlighting::{Color as SyntectColor, FontStyle, Style, Theme};\nuse syntect::parsing::SyntaxSet;\nuse syntect::{easy::HighlightLines, parsing::SyntaxReference};\n\n/// Comes from <https://github.com/sharkdp/bat/raw/5e77ca37e89c873e4490b42ff556370dc5c6ba4f/assets/syntaxes.bin>\nconst SYNTAXES: &[u8] = include_bytes!(\"../../assets/syntaxes.bin\");\n\nstatic LANG_MAPS: LazyLock<HashMap<String, String>> = LazyLock::new(|| {\n    let mut m = HashMap::new();\n    m.insert(\"csharp\".into(), \"C#\".into());\n    m.insert(\"php\".into(), \"PHP Source\".into());\n    m\n});\n\npub struct MarkdownRender {\n    options: RenderOptions,\n    syntax_set: SyntaxSet,\n    code_color: Option<Color>,\n    md_syntax: SyntaxReference,\n    code_syntax: Option<SyntaxReference>,\n    prev_line_type: LineType,\n    wrap_width: Option<u16>,\n}\n\nimpl MarkdownRender {\n    pub fn init(options: RenderOptions) -> Result<Self> {\n        let syntax_set: SyntaxSet =\n            decode_bin(SYNTAXES).with_context(|| \"MarkdownRender: invalid syntaxes binary\")?;\n\n        let code_color = options\n            .theme\n            .as_ref()\n            .map(|theme| get_code_color(theme, options.truecolor));\n        let md_syntax = syntax_set.find_syntax_by_extension(\"md\").unwrap().clone();\n        let line_type = LineType::Normal;\n        let wrap_width = match options.wrap.as_deref() {\n            None => None,\n            Some(value) => match terminal::size() {\n                Ok((columns, _)) => {\n                    if value == \"auto\" {\n                        Some(columns)\n                    } else {\n                        let value = value\n                            .parse::<u16>()\n                            .map_err(|_| anyhow!(\"Invalid wrap value\"))?;\n                        Some(columns.min(value))\n                    }\n                }\n                Err(_) => None,\n            },\n        };\n        Ok(Self {\n            syntax_set,\n            code_color,\n            md_syntax,\n            code_syntax: None,\n            prev_line_type: line_type,\n            wrap_width,\n            options,\n        })\n    }\n\n    pub fn render(&mut self, text: &str) -> String {\n        text.split('\\n')\n            .map(|line| self.render_line_mut(line))\n            .collect::<Vec<String>>()\n            .join(\"\\n\")\n    }\n\n    pub fn render_line(&self, line: &str) -> String {\n        let (_, code_syntax, is_code) = self.check_line(line);\n        if is_code {\n            self.highlight_code_line(line, &code_syntax)\n        } else {\n            self.highlight_line(line, &self.md_syntax, false)\n        }\n    }\n\n    fn render_line_mut(&mut self, line: &str) -> String {\n        let (line_type, code_syntax, is_code) = self.check_line(line);\n        let output = if is_code {\n            self.highlight_code_line(line, &code_syntax)\n        } else {\n            self.highlight_line(line, &self.md_syntax, false)\n        };\n        self.prev_line_type = line_type;\n        self.code_syntax = code_syntax;\n        output\n    }\n\n    fn check_line(&self, line: &str) -> (LineType, Option<SyntaxReference>, bool) {\n        let mut line_type = self.prev_line_type;\n        let mut code_syntax = self.code_syntax.clone();\n        let mut is_code = false;\n        if let Some(lang) = detect_code_block(line) {\n            match line_type {\n                LineType::Normal | LineType::CodeEnd => {\n                    line_type = LineType::CodeBegin;\n                    code_syntax = if lang.is_empty() {\n                        None\n                    } else {\n                        self.find_syntax(&lang).cloned()\n                    };\n                }\n                LineType::CodeBegin | LineType::CodeInner => {\n                    line_type = LineType::CodeEnd;\n                    code_syntax = None;\n                }\n            }\n        } else {\n            match line_type {\n                LineType::Normal => {}\n                LineType::CodeEnd => {\n                    line_type = LineType::Normal;\n                }\n                LineType::CodeBegin => {\n                    if code_syntax.is_none() {\n                        if let Some(syntax) = self.syntax_set.find_syntax_by_first_line(line) {\n                            code_syntax = Some(syntax.clone());\n                        }\n                    }\n                    line_type = LineType::CodeInner;\n                    is_code = true;\n                }\n                LineType::CodeInner => {\n                    is_code = true;\n                }\n            }\n        }\n        (line_type, code_syntax, is_code)\n    }\n\n    fn highlight_line(&self, line: &str, syntax: &SyntaxReference, is_code: bool) -> String {\n        let ws: String = line.chars().take_while(|c| c.is_whitespace()).collect();\n        let trimmed_line: &str = &line[ws.len()..];\n        let mut line_highlighted = None;\n        if let Some(theme) = &self.options.theme {\n            let mut highlighter = HighlightLines::new(syntax, theme);\n            if let Ok(ranges) = highlighter.highlight_line(trimmed_line, &self.syntax_set) {\n                line_highlighted = Some(format!(\n                    \"{ws}{}\",\n                    as_terminal_escaped(&ranges, self.options.truecolor)\n                ))\n            }\n        }\n        let line = line_highlighted.unwrap_or_else(|| line.into());\n        self.wrap_line(line, is_code)\n    }\n\n    fn highlight_code_line(&self, line: &str, code_syntax: &Option<SyntaxReference>) -> String {\n        if let Some(syntax) = code_syntax {\n            self.highlight_line(line, syntax, true)\n        } else {\n            let line = match self.code_color {\n                Some(color) => line.with(color).to_string(),\n                None => line.to_string(),\n            };\n            self.wrap_line(line, true)\n        }\n    }\n\n    fn wrap_line(&self, line: String, is_code: bool) -> String {\n        if let Some(width) = self.wrap_width {\n            if is_code && !self.options.wrap_code {\n                return line;\n            }\n            wrap(&line, width as usize)\n        } else {\n            line\n        }\n    }\n\n    fn find_syntax(&self, lang: &str) -> Option<&SyntaxReference> {\n        if let Some(new_lang) = LANG_MAPS.get(&lang.to_ascii_lowercase()) {\n            self.syntax_set.find_syntax_by_name(new_lang)\n        } else {\n            self.syntax_set\n                .find_syntax_by_token(lang)\n                .or_else(|| self.syntax_set.find_syntax_by_extension(lang))\n        }\n    }\n}\n\nfn wrap(text: &str, width: usize) -> String {\n    let indent: usize = text.chars().take_while(|c| *c == ' ').count();\n    let wrap_options = textwrap::Options::new(width)\n        .wrap_algorithm(textwrap::WrapAlgorithm::FirstFit)\n        .initial_indent(&text[0..indent]);\n    textwrap::wrap(&text[indent..], wrap_options).join(\"\\n\")\n}\n\n#[derive(Debug, Clone, Default)]\npub struct RenderOptions {\n    pub theme: Option<Theme>,\n    pub wrap: Option<String>,\n    pub wrap_code: bool,\n    pub truecolor: bool,\n}\n\nimpl RenderOptions {\n    pub(crate) fn new(\n        theme: Option<Theme>,\n        wrap: Option<String>,\n        wrap_code: bool,\n        truecolor: bool,\n    ) -> Self {\n        Self {\n            theme,\n            wrap,\n            wrap_code,\n            truecolor,\n        }\n    }\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub enum LineType {\n    Normal,\n    CodeBegin,\n    CodeInner,\n    CodeEnd,\n}\n\nfn as_terminal_escaped(ranges: &[(Style, &str)], truecolor: bool) -> String {\n    let mut output = String::new();\n    for (style, text) in ranges {\n        let fg = blend_fg_color(style.foreground, style.background);\n        let mut text = text.with(convert_color(fg, truecolor));\n        if style.font_style.contains(FontStyle::BOLD) {\n            text = text.bold();\n        }\n        if style.font_style.contains(FontStyle::UNDERLINE) {\n            text = text.underlined();\n        }\n        output.push_str(&text.to_string());\n    }\n    output\n}\n\nfn convert_color(c: SyntectColor, truecolor: bool) -> Color {\n    if truecolor {\n        Color::Rgb {\n            r: c.r,\n            g: c.g,\n            b: c.b,\n        }\n    } else {\n        let value = (c.r, c.g, c.b).to_ansi256();\n        // lower contrast\n        let value = match value {\n            7 | 15 | 231 | 252..=255 => 252,\n            _ => value,\n        };\n        Color::AnsiValue(value)\n    }\n}\n\nfn blend_fg_color(fg: SyntectColor, bg: SyntectColor) -> SyntectColor {\n    if fg.a == 0xff {\n        return fg;\n    }\n    let ratio = u32::from(fg.a);\n    let r = (u32::from(fg.r) * ratio + u32::from(bg.r) * (255 - ratio)) / 255;\n    let g = (u32::from(fg.g) * ratio + u32::from(bg.g) * (255 - ratio)) / 255;\n    let b = (u32::from(fg.b) * ratio + u32::from(bg.b) * (255 - ratio)) / 255;\n    SyntectColor {\n        r: u8::try_from(r).unwrap_or(u8::MAX),\n        g: u8::try_from(g).unwrap_or(u8::MAX),\n        b: u8::try_from(b).unwrap_or(u8::MAX),\n        a: 255,\n    }\n}\n\nfn detect_code_block(line: &str) -> Option<String> {\n    let line = line.trim_start();\n    if !line.starts_with(\"```\") {\n        return None;\n    }\n    let lang = line\n        .chars()\n        .skip(3)\n        .take_while(|v| !v.is_whitespace())\n        .collect();\n    Some(lang)\n}\n\nfn get_code_color(theme: &Theme, truecolor: bool) -> Color {\n    let scope = theme.scopes.iter().find(|v| {\n        v.scope\n            .selectors\n            .iter()\n            .any(|v| v.path.scopes.iter().any(|v| v.to_string() == \"string\"))\n    });\n    scope\n        .and_then(|v| v.style.foreground)\n        .map_or_else(|| Color::Yellow, |c| convert_color(c, truecolor))\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    const TEXT: &str = r#\"\nTo unzip a file in Rust, you can use the `zip` crate. Here's an example code that shows how to unzip a file:\n\n```rust\nuse std::fs::File;\n\nfn unzip_file(path: &str, output_dir: &str) -> Result<(), Box<dyn std::error::Error>> {\n    todo!()\n}\n```\n\"#;\n    const TEXT_NO_WRAP_CODE: &str = r#\"\nTo unzip a file in Rust, you can use the `zip` crate. Here's an example code\nthat shows how to unzip a file:\n\n```rust\nuse std::fs::File;\n\nfn unzip_file(path: &str, output_dir: &str) -> Result<(), Box<dyn std::error::Error>> {\n    todo!()\n}\n```\n\"#;\n\n    const TEXT_WRAP_ALL: &str = r#\"\nTo unzip a file in Rust, you can use the `zip` crate. Here's an example code\nthat shows how to unzip a file:\n\n```rust\nuse std::fs::File;\n\nfn unzip_file(path: &str, output_dir: &str) -> Result<(), Box<dyn\nstd::error::Error>> {\n    todo!()\n}\n```\n\"#;\n\n    #[test]\n    fn test_render() {\n        let options = RenderOptions::default();\n        let render = MarkdownRender::init(options).unwrap();\n        assert!(render.find_syntax(\"csharp\").is_some());\n    }\n\n    #[test]\n    fn no_theme() {\n        let options = RenderOptions::default();\n        let mut render = MarkdownRender::init(options).unwrap();\n        let output = render.render(TEXT);\n        assert_eq!(TEXT, output);\n    }\n\n    #[test]\n    fn no_wrap_code() {\n        let options = RenderOptions::default();\n        let mut render = MarkdownRender::init(options).unwrap();\n        render.wrap_width = Some(80);\n        let output = render.render(TEXT);\n        assert_eq!(TEXT_NO_WRAP_CODE, output);\n    }\n\n    #[test]\n    fn wrap_all() {\n        let options = RenderOptions {\n            wrap_code: true,\n            ..Default::default()\n        };\n        let mut render = MarkdownRender::init(options).unwrap();\n        render.wrap_width = Some(80);\n        let output = render.render(TEXT);\n        assert_eq!(TEXT_WRAP_ALL, output);\n    }\n\n    #[test]\n    fn test_detect_code_block() {\n        assert_eq!(detect_code_block(\"```rust\"), Some(\"rust\".into()));\n        assert_eq!(detect_code_block(\"```c++\"), Some(\"c++\".into()));\n        assert_eq!(detect_code_block(\"  ```rust\"), Some(\"rust\".into()));\n        assert_eq!(detect_code_block(\"```\"), Some(\"\".into()));\n        assert_eq!(detect_code_block(\"``rust\"), None);\n    }\n}\n"
  },
  {
    "path": "src/render/mod.rs",
    "content": "mod markdown;\nmod stream;\n\npub use self::markdown::{MarkdownRender, RenderOptions};\nuse self::stream::{markdown_stream, raw_stream};\n\nuse crate::utils::{error_text, pretty_error, AbortSignal, IS_STDOUT_TERMINAL};\nuse crate::{client::SseEvent, config::GlobalConfig};\n\nuse anyhow::Result;\nuse tokio::sync::mpsc::UnboundedReceiver;\n\npub async fn render_stream(\n    rx: UnboundedReceiver<SseEvent>,\n    config: &GlobalConfig,\n    abort_signal: AbortSignal,\n) -> Result<()> {\n    let ret = if *IS_STDOUT_TERMINAL && config.read().highlight {\n        let render_options = config.read().render_options()?;\n        let mut render = MarkdownRender::init(render_options)?;\n        markdown_stream(rx, &mut render, &abort_signal).await\n    } else {\n        raw_stream(rx, &abort_signal).await\n    };\n    ret.map_err(|err| err.context(\"Failed to reader stream\"))\n}\n\npub fn render_error(err: anyhow::Error) {\n    eprintln!(\"{}\", error_text(&pretty_error(&err)));\n}\n"
  },
  {
    "path": "src/render/stream.rs",
    "content": "use super::{MarkdownRender, SseEvent};\n\nuse crate::utils::{poll_abort_signal, spawn_spinner, AbortSignal};\n\nuse anyhow::Result;\nuse crossterm::{\n    cursor, queue, style,\n    terminal::{self, disable_raw_mode, enable_raw_mode},\n};\nuse std::{\n    io::{self, stdout, Stdout, Write},\n    time::Duration,\n};\nuse textwrap::core::display_width;\nuse tokio::sync::mpsc::UnboundedReceiver;\n\npub async fn markdown_stream(\n    rx: UnboundedReceiver<SseEvent>,\n    render: &mut MarkdownRender,\n    abort_signal: &AbortSignal,\n) -> Result<()> {\n    enable_raw_mode()?;\n    let mut stdout = io::stdout();\n\n    let ret = markdown_stream_inner(rx, render, abort_signal, &mut stdout).await;\n\n    disable_raw_mode()?;\n\n    if ret.is_err() {\n        println!();\n    }\n    ret\n}\n\npub async fn raw_stream(\n    mut rx: UnboundedReceiver<SseEvent>,\n    abort_signal: &AbortSignal,\n) -> Result<()> {\n    let mut spinner = Some(spawn_spinner(\"Generating\"));\n\n    loop {\n        if abort_signal.aborted() {\n            break;\n        }\n        if let Some(evt) = rx.recv().await {\n            if let Some(spinner) = spinner.take() {\n                spinner.stop();\n            }\n\n            match evt {\n                SseEvent::Text(text) => {\n                    print!(\"{text}\");\n                    stdout().flush()?;\n                }\n                SseEvent::Done => {\n                    break;\n                }\n            }\n        }\n    }\n    if let Some(spinner) = spinner.take() {\n        spinner.stop();\n    }\n    Ok(())\n}\n\nasync fn markdown_stream_inner(\n    mut rx: UnboundedReceiver<SseEvent>,\n    render: &mut MarkdownRender,\n    abort_signal: &AbortSignal,\n    writer: &mut Stdout,\n) -> Result<()> {\n    let mut buffer = String::new();\n    let mut buffer_rows = 1;\n\n    let columns = terminal::size()?.0;\n\n    let mut spinner = Some(spawn_spinner(\"Generating\"));\n\n    'outer: loop {\n        if abort_signal.aborted() {\n            break;\n        }\n        for reply_event in gather_events(&mut rx).await {\n            if let Some(spinner) = spinner.take() {\n                spinner.stop();\n            }\n\n            match reply_event {\n                SseEvent::Text(mut text) => {\n                    // tab width hacking\n                    text = text.replace('\\t', \"    \");\n\n                    let mut attempts = 0;\n                    let (col, mut row) = loop {\n                        match cursor::position() {\n                            Ok(pos) => break pos,\n                            Err(_) if attempts < 3 => attempts += 1,\n                            Err(e) => return Err(e.into()),\n                        }\n                    };\n\n                    // Fix unexpected duplicate lines on kitty, see https://github.com/sigoden/aichat/issues/105\n                    if col == 0 && row > 0 && display_width(&buffer) == columns as usize {\n                        row -= 1;\n                    }\n\n                    if row + 1 >= buffer_rows {\n                        queue!(writer, cursor::MoveTo(0, row + 1 - buffer_rows),)?;\n                    } else {\n                        let scroll_rows = buffer_rows - row - 1;\n                        queue!(\n                            writer,\n                            terminal::ScrollUp(scroll_rows),\n                            cursor::MoveTo(0, 0),\n                        )?;\n                    }\n\n                    // No guarantee that text returned by render will not be re-layouted, so it is better to clear it.\n                    queue!(writer, terminal::Clear(terminal::ClearType::FromCursorDown))?;\n\n                    if text.contains('\\n') {\n                        let text = format!(\"{buffer}{text}\");\n                        let (head, tail) = split_line_tail(&text);\n                        let output = render.render(head);\n                        print_block(writer, &output, columns)?;\n                        buffer = tail.to_string();\n                    } else {\n                        buffer = format!(\"{buffer}{text}\");\n                    }\n\n                    let output = render.render_line(&buffer);\n                    if output.contains('\\n') {\n                        let (head, tail) = split_line_tail(&output);\n                        buffer_rows = print_block(writer, head, columns)?;\n                        queue!(writer, style::Print(&tail),)?;\n\n                        // No guarantee the buffer width of the buffer will not exceed the number of columns.\n                        // So we calculate the number of rows needed, rather than setting it directly to 1.\n                        buffer_rows += need_rows(tail, columns);\n                    } else {\n                        queue!(writer, style::Print(&output))?;\n                        buffer_rows = need_rows(&output, columns);\n                    }\n\n                    writer.flush()?;\n                }\n                SseEvent::Done => {\n                    break 'outer;\n                }\n            }\n        }\n\n        if poll_abort_signal(abort_signal)? {\n            break;\n        }\n    }\n\n    if let Some(spinner) = spinner.take() {\n        spinner.stop();\n    }\n    Ok(())\n}\n\nasync fn gather_events(rx: &mut UnboundedReceiver<SseEvent>) -> Vec<SseEvent> {\n    let mut texts = vec![];\n    let mut done = false;\n    tokio::select! {\n        _ = async {\n            while let Some(reply_event) = rx.recv().await {\n                match reply_event {\n                    SseEvent::Text(v) => texts.push(v),\n                    SseEvent::Done => {\n                        done = true;\n                        break;\n                    }\n                }\n            }\n        } => {}\n        _ = tokio::time::sleep(Duration::from_millis(50)) => {}\n    };\n    let mut events = vec![];\n    if !texts.is_empty() {\n        events.push(SseEvent::Text(texts.join(\"\")))\n    }\n    if done {\n        events.push(SseEvent::Done)\n    }\n    events\n}\n\nfn print_block(writer: &mut Stdout, text: &str, columns: u16) -> Result<u16> {\n    let mut num = 0;\n    for line in text.split('\\n') {\n        queue!(\n            writer,\n            style::Print(line),\n            style::Print(\"\\n\"),\n            cursor::MoveLeft(columns),\n        )?;\n        num += 1;\n    }\n    Ok(num)\n}\n\nfn split_line_tail(text: &str) -> (&str, &str) {\n    if let Some((head, tail)) = text.rsplit_once('\\n') {\n        (head, tail)\n    } else {\n        (\"\", text)\n    }\n}\n\nfn need_rows(text: &str, columns: u16) -> u16 {\n    let buffer_width = display_width(text).max(1) as u16;\n    buffer_width.div_ceil(columns)\n}\n"
  },
  {
    "path": "src/repl/completer.rs",
    "content": "use super::{ReplCommand, REPL_COMMANDS};\n\nuse crate::{config::GlobalConfig, utils::fuzzy_filter};\n\nuse reedline::{Completer, Span, Suggestion};\nuse std::collections::HashMap;\n\nimpl Completer for ReplCompleter {\n    fn complete(&mut self, line: &str, pos: usize) -> Vec<Suggestion> {\n        let mut suggestions = vec![];\n        let line = &line[0..pos];\n        let mut parts = split_line(line);\n        if parts.is_empty() {\n            return suggestions;\n        }\n        if parts[0].0 == r#\":::\"# {\n            parts.remove(0);\n        }\n\n        let parts_len = parts.len();\n        if parts_len == 0 {\n            return suggestions;\n        }\n        let (cmd, cmd_start) = parts[0];\n\n        if !cmd.starts_with('.') {\n            return suggestions;\n        }\n\n        let state = self.config.read().state();\n\n        let command_filter = parts\n            .iter()\n            .take(2)\n            .map(|(v, _)| *v)\n            .collect::<Vec<&str>>()\n            .join(\" \");\n        let commands: Vec<_> = self\n            .commands\n            .iter()\n            .filter(|cmd| {\n                cmd.is_valid(state)\n                    && (command_filter.len() == 1 || cmd.name.starts_with(&command_filter[..2]))\n            })\n            .collect();\n        let commands = fuzzy_filter(commands, |v| v.name, &command_filter);\n\n        if parts_len > 1 {\n            let span = Span::new(parts[parts_len - 1].1, pos);\n            let args_line = &line[parts[1].1..];\n            let args: Vec<&str> = parts.iter().skip(1).map(|(v, _)| *v).collect();\n            suggestions.extend(\n                self.config\n                    .read()\n                    .repl_complete(cmd, &args, args_line)\n                    .iter()\n                    .map(|(value, description)| {\n                        let description = description.as_deref().unwrap_or_default();\n                        create_suggestion(value, description, span)\n                    }),\n            )\n        }\n\n        if suggestions.is_empty() {\n            let span = Span::new(cmd_start, pos);\n            suggestions.extend(commands.iter().map(|cmd| {\n                let name = cmd.name;\n                let description = cmd.description;\n                let has_group = self.groups.get(name).map(|v| *v > 1).unwrap_or_default();\n                let name = if has_group {\n                    name.to_string()\n                } else {\n                    format!(\"{name} \")\n                };\n                create_suggestion(&name, description, span)\n            }))\n        }\n        suggestions\n    }\n}\n\npub struct ReplCompleter {\n    config: GlobalConfig,\n    commands: Vec<ReplCommand>,\n    groups: HashMap<&'static str, usize>,\n}\n\nimpl ReplCompleter {\n    pub fn new(config: &GlobalConfig) -> Self {\n        let mut groups = HashMap::new();\n\n        let commands: Vec<ReplCommand> = REPL_COMMANDS.to_vec();\n\n        for cmd in REPL_COMMANDS.iter() {\n            let name = cmd.name;\n            if let Some(count) = groups.get(name) {\n                groups.insert(name, count + 1);\n            } else {\n                groups.insert(name, 1);\n            }\n        }\n\n        Self {\n            config: config.clone(),\n            commands,\n            groups,\n        }\n    }\n}\n\nfn create_suggestion(value: &str, description: &str, span: Span) -> Suggestion {\n    let description = if description.is_empty() {\n        None\n    } else {\n        Some(description.to_string())\n    };\n    Suggestion {\n        value: value.to_string(),\n        description,\n        style: None,\n        extra: None,\n        span,\n        append_whitespace: false,\n    }\n}\n\nfn split_line(line: &str) -> Vec<(&str, usize)> {\n    let mut parts = vec![];\n    let mut part_start = None;\n    for (i, ch) in line.char_indices() {\n        if ch == ' ' {\n            if let Some(s) = part_start {\n                parts.push((&line[s..i], s));\n                part_start = None;\n            }\n        } else if part_start.is_none() {\n            part_start = Some(i)\n        }\n    }\n    if let Some(s) = part_start {\n        parts.push((&line[s..], s));\n    } else {\n        parts.push((\"\", line.len()))\n    }\n    parts\n}\n\n#[test]\nfn test_split_line() {\n    assert_eq!(split_line(\".role coder\"), vec![(\".role\", 0), (\"coder\", 6)],);\n    assert_eq!(\n        split_line(\" .role   coder\"),\n        vec![(\".role\", 1), (\"coder\", 9)],\n    );\n    assert_eq!(\n        split_line(\".set highlight \"),\n        vec![(\".set\", 0), (\"highlight\", 5), (\"\", 15)],\n    );\n    assert_eq!(\n        split_line(\".set highlight t\"),\n        vec![(\".set\", 0), (\"highlight\", 5), (\"t\", 15)],\n    );\n}\n"
  },
  {
    "path": "src/repl/highlighter.rs",
    "content": "use super::REPL_COMMANDS;\n\nuse crate::{config::GlobalConfig, utils::NO_COLOR};\n\nuse nu_ansi_term::{Color, Style};\nuse reedline::{Highlighter, StyledText};\n\nconst DEFAULT_COLOR: Color = Color::Default;\nconst MATCH_COLOR: Color = Color::Green;\n\npub struct ReplHighlighter;\n\nimpl ReplHighlighter {\n    pub fn new(_config: &GlobalConfig) -> Self {\n        Self\n    }\n}\n\nimpl Highlighter for ReplHighlighter {\n    fn highlight(&self, line: &str, _cursor: usize) -> StyledText {\n        let mut styled_text = StyledText::new();\n\n        if *NO_COLOR {\n            styled_text.push((Style::default(), line.to_string()));\n        } else if REPL_COMMANDS.iter().any(|cmd| line.contains(cmd.name)) {\n            let matches: Vec<&str> = REPL_COMMANDS\n                .iter()\n                .filter(|cmd| line.contains(cmd.name))\n                .map(|cmd| cmd.name)\n                .collect();\n            let longest_match = matches.iter().fold(String::new(), |acc, &item| {\n                if item.len() > acc.len() {\n                    item.to_string()\n                } else {\n                    acc\n                }\n            });\n            let buffer_split: Vec<&str> = line.splitn(2, &longest_match).collect();\n\n            styled_text.push((Style::new().fg(DEFAULT_COLOR), buffer_split[0].to_string()));\n            styled_text.push((Style::new().fg(MATCH_COLOR), longest_match));\n            styled_text.push((Style::new().fg(DEFAULT_COLOR), buffer_split[1].to_string()));\n        } else {\n            styled_text.push((Style::new().fg(DEFAULT_COLOR), line.to_string()));\n        }\n\n        styled_text\n    }\n}\n"
  },
  {
    "path": "src/repl/mod.rs",
    "content": "mod completer;\nmod highlighter;\nmod prompt;\n\nuse self::completer::ReplCompleter;\nuse self::highlighter::ReplHighlighter;\nuse self::prompt::ReplPrompt;\n\nuse crate::client::{call_chat_completions, call_chat_completions_streaming};\nuse crate::config::{\n    macro_execute, AgentVariables, AssertState, Config, GlobalConfig, Input, LastMessage,\n    StateFlags,\n};\nuse crate::render::render_error;\nuse crate::utils::{\n    abortable_run_with_spinner, create_abort_signal, dimmed_text, set_text, temp_file, AbortSignal,\n};\n\nuse anyhow::{bail, Context, Result};\nuse crossterm::cursor::SetCursorStyle;\nuse fancy_regex::Regex;\nuse reedline::CursorConfig;\nuse reedline::{\n    default_emacs_keybindings, default_vi_insert_keybindings, default_vi_normal_keybindings,\n    ColumnarMenu, EditCommand, EditMode, Emacs, KeyCode, KeyModifiers, Keybindings, Reedline,\n    ReedlineEvent, ReedlineMenu, ValidationResult, Validator, Vi,\n};\nuse reedline::{MenuBuilder, Signal};\nuse std::sync::LazyLock;\nuse std::{env, process};\n\nconst MENU_NAME: &str = \"completion_menu\";\n\nstatic REPL_COMMANDS: LazyLock<[ReplCommand; 36]> = LazyLock::new(|| {\n    [\n        ReplCommand::new(\".help\", \"Show this help guide\", AssertState::pass()),\n        ReplCommand::new(\".info\", \"Show system info\", AssertState::pass()),\n        ReplCommand::new(\n            \".edit config\",\n            \"Modify configuration file\",\n            AssertState::False(StateFlags::AGENT),\n        ),\n        ReplCommand::new(\".model\", \"Switch LLM model\", AssertState::pass()),\n        ReplCommand::new(\n            \".prompt\",\n            \"Set a temporary role using a prompt\",\n            AssertState::False(StateFlags::SESSION | StateFlags::AGENT),\n        ),\n        ReplCommand::new(\n            \".role\",\n            \"Create or switch to a role\",\n            AssertState::False(StateFlags::SESSION | StateFlags::AGENT),\n        ),\n        ReplCommand::new(\n            \".info role\",\n            \"Show role info\",\n            AssertState::True(StateFlags::ROLE),\n        ),\n        ReplCommand::new(\n            \".edit role\",\n            \"Modify current role\",\n            AssertState::TrueFalse(StateFlags::ROLE, StateFlags::SESSION),\n        ),\n        ReplCommand::new(\n            \".save role\",\n            \"Save current role to file\",\n            AssertState::TrueFalse(\n                StateFlags::ROLE,\n                StateFlags::SESSION_EMPTY | StateFlags::SESSION,\n            ),\n        ),\n        ReplCommand::new(\n            \".exit role\",\n            \"Exit active role\",\n            AssertState::TrueFalse(StateFlags::ROLE, StateFlags::SESSION),\n        ),\n        ReplCommand::new(\n            \".session\",\n            \"Start or switch to a session\",\n            AssertState::False(StateFlags::SESSION_EMPTY | StateFlags::SESSION),\n        ),\n        ReplCommand::new(\n            \".empty session\",\n            \"Clear session messages\",\n            AssertState::True(StateFlags::SESSION),\n        ),\n        ReplCommand::new(\n            \".compress session\",\n            \"Compress session messages\",\n            AssertState::True(StateFlags::SESSION),\n        ),\n        ReplCommand::new(\n            \".info session\",\n            \"Show session info\",\n            AssertState::True(StateFlags::SESSION_EMPTY | StateFlags::SESSION),\n        ),\n        ReplCommand::new(\n            \".edit session\",\n            \"Modify current session\",\n            AssertState::True(StateFlags::SESSION_EMPTY | StateFlags::SESSION),\n        ),\n        ReplCommand::new(\n            \".save session\",\n            \"Save current session to file\",\n            AssertState::True(StateFlags::SESSION_EMPTY | StateFlags::SESSION),\n        ),\n        ReplCommand::new(\n            \".exit session\",\n            \"Exit active session\",\n            AssertState::True(StateFlags::SESSION_EMPTY | StateFlags::SESSION),\n        ),\n        ReplCommand::new(\".agent\", \"Use an agent\", AssertState::bare()),\n        ReplCommand::new(\n            \".starter\",\n            \"Use a conversation starter\",\n            AssertState::True(StateFlags::AGENT),\n        ),\n        ReplCommand::new(\n            \".edit agent-config\",\n            \"Modify agent configuration file\",\n            AssertState::True(StateFlags::AGENT),\n        ),\n        ReplCommand::new(\n            \".info agent\",\n            \"Show agent info\",\n            AssertState::True(StateFlags::AGENT),\n        ),\n        ReplCommand::new(\n            \".exit agent\",\n            \"Leave agent\",\n            AssertState::True(StateFlags::AGENT),\n        ),\n        ReplCommand::new(\n            \".rag\",\n            \"Initialize or access RAG\",\n            AssertState::False(StateFlags::AGENT),\n        ),\n        ReplCommand::new(\n            \".edit rag-docs\",\n            \"Add or remove documents from an existing RAG\",\n            AssertState::TrueFalse(StateFlags::RAG, StateFlags::AGENT),\n        ),\n        ReplCommand::new(\n            \".rebuild rag\",\n            \"Rebuild RAG for document changes\",\n            AssertState::True(StateFlags::RAG),\n        ),\n        ReplCommand::new(\n            \".sources rag\",\n            \"Show citation sources used in last query\",\n            AssertState::True(StateFlags::RAG),\n        ),\n        ReplCommand::new(\n            \".info rag\",\n            \"Show RAG info\",\n            AssertState::True(StateFlags::RAG),\n        ),\n        ReplCommand::new(\n            \".exit rag\",\n            \"Leave RAG\",\n            AssertState::TrueFalse(StateFlags::RAG, StateFlags::AGENT),\n        ),\n        ReplCommand::new(\".macro\", \"Execute a macro\", AssertState::pass()),\n        ReplCommand::new(\n            \".file\",\n            \"Include files, directories, URLs or commands\",\n            AssertState::pass(),\n        ),\n        ReplCommand::new(\n            \".continue\",\n            \"Continue previous response\",\n            AssertState::pass(),\n        ),\n        ReplCommand::new(\n            \".regenerate\",\n            \"Regenerate last response\",\n            AssertState::pass(),\n        ),\n        ReplCommand::new(\".copy\", \"Copy last response\", AssertState::pass()),\n        ReplCommand::new(\".set\", \"Modify runtime settings\", AssertState::pass()),\n        ReplCommand::new(\n            \".delete\",\n            \"Delete roles, sessions, RAGs, or agents\",\n            AssertState::pass(),\n        ),\n        ReplCommand::new(\".exit\", \"Exit REPL\", AssertState::pass()),\n    ]\n});\nstatic COMMAND_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r\"^\\s*(\\.\\S*)\\s*\").unwrap());\nstatic MULTILINE_RE: LazyLock<Regex> =\n    LazyLock::new(|| Regex::new(r\"(?s)^\\s*:::\\s*(.*)\\s*:::\\s*$\").unwrap());\n\npub struct Repl {\n    config: GlobalConfig,\n    editor: Reedline,\n    prompt: ReplPrompt,\n    abort_signal: AbortSignal,\n}\n\nimpl Repl {\n    pub fn init(config: &GlobalConfig) -> Result<Self> {\n        let editor = Self::create_editor(config)?;\n\n        let prompt = ReplPrompt::new(config);\n        let abort_signal = create_abort_signal();\n\n        Ok(Self {\n            config: config.clone(),\n            editor,\n            prompt,\n            abort_signal,\n        })\n    }\n\n    pub async fn run(&mut self) -> Result<()> {\n        if AssertState::False(StateFlags::AGENT | StateFlags::RAG)\n            .assert(self.config.read().state())\n        {\n            print!(\n                r#\"Welcome to {} {}\nType \".help\" for additional help.\n\"#,\n                env!(\"CARGO_CRATE_NAME\"),\n                env!(\"CARGO_PKG_VERSION\"),\n            )\n        }\n\n        loop {\n            if self.abort_signal.aborted_ctrld() {\n                break;\n            }\n            let sig = self.editor.read_line(&self.prompt);\n            match sig {\n                Ok(Signal::Success(line)) => {\n                    self.abort_signal.reset();\n                    match run_repl_command(&self.config, self.abort_signal.clone(), &line).await {\n                        Ok(exit) => {\n                            if exit {\n                                break;\n                            }\n                        }\n                        Err(err) => {\n                            render_error(err);\n                            println!()\n                        }\n                    }\n                }\n                Ok(Signal::CtrlC) => {\n                    self.abort_signal.set_ctrlc();\n                    println!(\"(To exit, press Ctrl+D or enter \\\".exit\\\")\\n\");\n                }\n                Ok(Signal::CtrlD) => {\n                    self.abort_signal.set_ctrld();\n                    break;\n                }\n                _ => {}\n            }\n        }\n        self.config.write().exit_session()?;\n        Ok(())\n    }\n\n    fn create_editor(config: &GlobalConfig) -> Result<Reedline> {\n        let completer = ReplCompleter::new(config);\n        let highlighter = ReplHighlighter::new(config);\n        let menu = Self::create_menu();\n        let edit_mode = Self::create_edit_mode(config);\n        let cursor_config = CursorConfig {\n            vi_insert: Some(SetCursorStyle::BlinkingBar),\n            vi_normal: Some(SetCursorStyle::SteadyBlock),\n            emacs: None,\n        };\n        let mut editor = Reedline::create()\n            .with_completer(Box::new(completer))\n            .with_highlighter(Box::new(highlighter))\n            .with_menu(menu)\n            .with_edit_mode(edit_mode)\n            .with_cursor_config(cursor_config)\n            .with_quick_completions(true)\n            .with_partial_completions(true)\n            .use_bracketed_paste(true)\n            .with_validator(Box::new(ReplValidator))\n            .with_ansi_colors(true);\n\n        if let Ok(cmd) = config.read().editor() {\n            let temp_file = temp_file(\"-repl-\", \".md\");\n            let command = process::Command::new(cmd);\n            editor = editor.with_buffer_editor(command, temp_file);\n        }\n\n        Ok(editor)\n    }\n\n    fn extra_keybindings(keybindings: &mut Keybindings) {\n        keybindings.add_binding(\n            KeyModifiers::NONE,\n            KeyCode::Tab,\n            ReedlineEvent::UntilFound(vec![\n                ReedlineEvent::Menu(MENU_NAME.to_string()),\n                ReedlineEvent::MenuNext,\n            ]),\n        );\n        keybindings.add_binding(\n            KeyModifiers::SHIFT,\n            KeyCode::BackTab,\n            ReedlineEvent::MenuPrevious,\n        );\n        keybindings.add_binding(\n            KeyModifiers::CONTROL,\n            KeyCode::Enter,\n            ReedlineEvent::Edit(vec![EditCommand::InsertNewline]),\n        );\n        keybindings.add_binding(\n            KeyModifiers::CONTROL,\n            KeyCode::Char('j'),\n            ReedlineEvent::Edit(vec![EditCommand::InsertNewline]),\n        );\n    }\n\n    fn create_edit_mode(config: &GlobalConfig) -> Box<dyn EditMode> {\n        let edit_mode: Box<dyn EditMode> = if config.read().keybindings == \"vi\" {\n            let mut insert_keybindings = default_vi_insert_keybindings();\n            Self::extra_keybindings(&mut insert_keybindings);\n            Box::new(Vi::new(insert_keybindings, default_vi_normal_keybindings()))\n        } else {\n            let mut keybindings = default_emacs_keybindings();\n            Self::extra_keybindings(&mut keybindings);\n            Box::new(Emacs::new(keybindings))\n        };\n        edit_mode\n    }\n\n    fn create_menu() -> ReedlineMenu {\n        let completion_menu = ColumnarMenu::default().with_name(MENU_NAME);\n        ReedlineMenu::EngineCompleter(Box::new(completion_menu))\n    }\n}\n\n#[derive(Debug, Clone)]\npub struct ReplCommand {\n    name: &'static str,\n    description: &'static str,\n    state: AssertState,\n}\n\nimpl ReplCommand {\n    fn new(name: &'static str, desc: &'static str, state: AssertState) -> Self {\n        Self {\n            name,\n            description: desc,\n            state,\n        }\n    }\n\n    fn is_valid(&self, flags: StateFlags) -> bool {\n        self.state.assert(flags)\n    }\n}\n\n/// A default validator which checks for mismatched quotes and brackets\nstruct ReplValidator;\n\nimpl Validator for ReplValidator {\n    fn validate(&self, line: &str) -> ValidationResult {\n        let line = line.trim();\n        if line.starts_with(r#\":::\"#) && !line[3..].ends_with(r#\":::\"#) {\n            ValidationResult::Incomplete\n        } else {\n            ValidationResult::Complete\n        }\n    }\n}\n\npub async fn run_repl_command(\n    config: &GlobalConfig,\n    abort_signal: AbortSignal,\n    mut line: &str,\n) -> Result<bool> {\n    if let Ok(Some(captures)) = MULTILINE_RE.captures(line) {\n        if let Some(text_match) = captures.get(1) {\n            line = text_match.as_str();\n        }\n    }\n    match parse_command(line) {\n        Some((cmd, args)) => match cmd {\n            \".help\" => {\n                dump_repl_help();\n            }\n            \".info\" => match args {\n                Some(\"role\") => {\n                    let info = config.read().role_info()?;\n                    print!(\"{info}\");\n                }\n                Some(\"session\") => {\n                    let info = config.read().session_info()?;\n                    print!(\"{info}\");\n                }\n                Some(\"rag\") => {\n                    let info = config.read().rag_info()?;\n                    print!(\"{info}\");\n                }\n                Some(\"agent\") => {\n                    let info = config.read().agent_info()?;\n                    print!(\"{info}\");\n                }\n                Some(_) => unknown_command()?,\n                None => {\n                    let output = config.read().sysinfo()?;\n                    print!(\"{output}\");\n                }\n            },\n            \".model\" => match args {\n                Some(name) => {\n                    config.write().set_model(name)?;\n                }\n                None => println!(\"Usage: .model <name>\"),\n            },\n            \".prompt\" => match args {\n                Some(text) => {\n                    config.write().use_prompt(text)?;\n                }\n                None => println!(\"Usage: .prompt <text>...\"),\n            },\n            \".role\" => match args {\n                Some(args) => match args.split_once(['\\n', ' ']) {\n                    Some((name, text)) => {\n                        let role = config.read().retrieve_role(name.trim())?;\n                        let input = Input::from_str(config, text, Some(role));\n                        ask(config, abort_signal.clone(), input, false).await?;\n                    }\n                    None => {\n                        let name = args;\n                        if !Config::has_role(name) {\n                            config.write().new_role(name)?;\n                        }\n                        config.write().use_role(name)?;\n                    }\n                },\n                None => println!(\n                    r#\"Usage:\n    .role <name>                    # If the role exists, switch to it; otherwise, create a new role\n    .role <name> [text]...          # Temporarily switch to the role, send the text, and switch back\"#\n                ),\n            },\n            \".session\" => {\n                config.write().use_session(args)?;\n                Config::maybe_autoname_session(config.clone());\n            }\n            \".rag\" => {\n                Config::use_rag(config, args, abort_signal.clone()).await?;\n            }\n            \".agent\" => match split_first_arg(args) {\n                Some((agent_name, args)) => {\n                    let (new_args, _) = split_args_text(args.unwrap_or_default(), cfg!(windows));\n                    let (session_name, variable_pairs) = match new_args.first() {\n                        Some(name) if name.contains('=') => (None, new_args.as_slice()),\n                        Some(name) => (Some(name.as_str()), &new_args[1..]),\n                        None => (None, &[] as &[String]),\n                    };\n                    let variables: AgentVariables = variable_pairs\n                        .iter()\n                        .filter_map(|v| v.split_once('='))\n                        .map(|(key, value)| (key.to_string(), value.to_string()))\n                        .collect();\n                    if variables.len() != variable_pairs.len() {\n                        bail!(\"Some variable values are not key=value pairs\");\n                    }\n                    if !variables.is_empty() {\n                        config.write().agent_variables = Some(variables);\n                    }\n                    let ret =\n                        Config::use_agent(config, agent_name, session_name, abort_signal.clone())\n                            .await;\n                    config.write().agent_variables = None;\n                    ret?;\n                }\n                None => {\n                    println!(r#\"Usage: .agent <agent-name> [session-name] [key=value]...\"#)\n                }\n            },\n            \".starter\" => match args {\n                Some(id) => {\n                    let mut text = None;\n                    if let Some(agent) = config.read().agent.as_ref() {\n                        for (i, value) in agent.conversation_staters().iter().enumerate() {\n                            if (i + 1).to_string() == id {\n                                text = Some(value.clone());\n                            }\n                        }\n                    }\n                    match text {\n                        Some(text) => {\n                            println!(\"{}\", dimmed_text(&format!(\">> {text}\")));\n                            let input = Input::from_str(config, &text, None);\n                            ask(config, abort_signal.clone(), input, true).await?;\n                        }\n                        None => {\n                            bail!(\"Invalid starter value\");\n                        }\n                    }\n                }\n                None => {\n                    let banner = config.read().agent_banner()?;\n                    config.read().print_markdown(&banner)?;\n                }\n            },\n            \".save\" => match split_first_arg(args) {\n                Some((\"role\", name)) => {\n                    config.write().save_role(name)?;\n                }\n                Some((\"session\", name)) => {\n                    config.write().save_session(name)?;\n                }\n                _ => {\n                    println!(r#\"Usage: .save <role|session> [name]\"#)\n                }\n            },\n            \".edit\" => {\n                if config.read().macro_flag {\n                    bail!(\"Cannot perform this operation because you are in a macro\")\n                }\n                match args {\n                    Some(\"config\") => {\n                        config.read().edit_config()?;\n                    }\n                    Some(\"role\") => {\n                        config.write().edit_role()?;\n                    }\n                    Some(\"session\") => {\n                        config.write().edit_session()?;\n                    }\n                    Some(\"rag-docs\") => {\n                        Config::edit_rag_docs(config, abort_signal.clone()).await?;\n                    }\n                    Some(\"agent-config\") => {\n                        config.write().edit_agent_config()?;\n                    }\n                    _ => {\n                        println!(r#\"Usage: .edit <config|role|session|rag-docs|agent-config>\"#)\n                    }\n                }\n            }\n            \".compress\" => match args {\n                Some(\"session\") => {\n                    abortable_run_with_spinner(\n                        Config::compress_session(config),\n                        \"Compressing\",\n                        abort_signal.clone(),\n                    )\n                    .await?;\n                    println!(\"✓ Successfully compressed the session.\");\n                }\n                _ => {\n                    println!(r#\"Usage: .compress session\"#)\n                }\n            },\n            \".empty\" => match args {\n                Some(\"session\") => {\n                    config.write().empty_session()?;\n                }\n                _ => {\n                    println!(r#\"Usage: .empty session\"#)\n                }\n            },\n            \".rebuild\" => match args {\n                Some(\"rag\") => {\n                    Config::rebuild_rag(config, abort_signal.clone()).await?;\n                }\n                _ => {\n                    println!(r#\"Usage: .rebuild rag\"#)\n                }\n            },\n            \".sources\" => match args {\n                Some(\"rag\") => {\n                    let output = Config::rag_sources(config)?;\n                    println!(\"{output}\");\n                }\n                _ => {\n                    println!(r#\"Usage: .sources rag\"#)\n                }\n            },\n            \".macro\" => match split_first_arg(args) {\n                Some((name, extra)) => {\n                    if !Config::has_macro(name) && extra.is_none() {\n                        config.write().new_macro(name)?;\n                    } else {\n                        macro_execute(config, name, extra, abort_signal.clone()).await?;\n                    }\n                }\n                None => println!(\"Usage: .macro <name> <text>...\"),\n            },\n            \".file\" => match args {\n                Some(args) => {\n                    let (files, text) = split_args_text(args, cfg!(windows));\n                    let input = Input::from_files_with_spinner(\n                        config,\n                        text,\n                        files,\n                        None,\n                        abort_signal.clone(),\n                    )\n                    .await?;\n                    ask(config, abort_signal.clone(), input, true).await?;\n                }\n                None => println!(\n                    r#\"Usage: .file <file|dir|url|cmd|loader:resource|%%>... [-- <text>...]\n\n.file /tmp/file.txt\n.file src/ Cargo.toml -- analyze\n.file https://example.com/file.txt -- summarize\n.file https://example.com/image.png -- recognize text\n.file `git diff` -- Generate git commit message\n.file jina:https://example.com\n.file %% -- translate last reply to english\"#\n                ),\n            },\n            \".continue\" => {\n                let LastMessage {\n                    mut input, output, ..\n                } = match config\n                    .read()\n                    .last_message\n                    .as_ref()\n                    .filter(|v| v.continuous && !v.output.is_empty())\n                    .cloned()\n                {\n                    Some(v) => v,\n                    None => bail!(\"Unable to continue the response\"),\n                };\n                input.set_continue_output(&output);\n                ask(config, abort_signal.clone(), input, true).await?;\n            }\n            \".regenerate\" => {\n                let LastMessage { mut input, .. } = match config\n                    .read()\n                    .last_message\n                    .as_ref()\n                    .filter(|v| v.continuous)\n                    .cloned()\n                {\n                    Some(v) => v,\n                    None => bail!(\"Unable to regenerate the response\"),\n                };\n                input.set_regenerate();\n                ask(config, abort_signal.clone(), input, true).await?;\n            }\n            \".set\" => match args {\n                Some(args) => {\n                    Config::update(config, args)?;\n                }\n                _ => {\n                    println!(\"Usage: .set <key> <value>...\")\n                }\n            },\n            \".delete\" => match args {\n                Some(args) => {\n                    Config::delete(config, args)?;\n                }\n                _ => {\n                    println!(\"Usage: .delete <role|session|rag|macro|agent-data>\")\n                }\n            },\n            \".copy\" => {\n                let output = match config\n                    .read()\n                    .last_message\n                    .as_ref()\n                    .filter(|v| !v.output.is_empty())\n                    .map(|v| v.output.clone())\n                {\n                    Some(v) => v,\n                    None => bail!(\"No chat response to copy\"),\n                };\n                set_text(&output).context(\"Failed to copy the last chat response\")?;\n            }\n            \".exit\" => match args {\n                Some(\"role\") => {\n                    config.write().exit_role()?;\n                }\n                Some(\"session\") => {\n                    if config.read().agent.is_some() {\n                        config.write().exit_agent_session()?;\n                    } else {\n                        config.write().exit_session()?;\n                    }\n                }\n                Some(\"rag\") => {\n                    config.write().exit_rag()?;\n                }\n                Some(\"agent\") => {\n                    config.write().exit_agent()?;\n                }\n                Some(_) => unknown_command()?,\n                None => {\n                    return Ok(true);\n                }\n            },\n            \".clear\" => match args {\n                Some(\"messages\") => {\n                    bail!(\"Use '.empty session' instead\");\n                }\n                _ => unknown_command()?,\n            },\n            _ => unknown_command()?,\n        },\n        None => {\n            let input = Input::from_str(config, line, None);\n            ask(config, abort_signal.clone(), input, true).await?;\n        }\n    }\n\n    if !config.read().macro_flag {\n        println!();\n    }\n\n    Ok(false)\n}\n\n#[async_recursion::async_recursion]\nasync fn ask(\n    config: &GlobalConfig,\n    abort_signal: AbortSignal,\n    mut input: Input,\n    with_embeddings: bool,\n) -> Result<()> {\n    if input.is_empty() {\n        return Ok(());\n    }\n    if with_embeddings {\n        input.use_embeddings(abort_signal.clone()).await?;\n    }\n    while config.read().is_compressing_session() {\n        tokio::time::sleep(std::time::Duration::from_millis(100)).await;\n    }\n\n    let client = input.create_client()?;\n    config.write().before_chat_completion(&input)?;\n    let (output, tool_results) = if input.stream() {\n        call_chat_completions_streaming(&input, client.as_ref(), abort_signal.clone()).await?\n    } else {\n        call_chat_completions(&input, true, false, client.as_ref(), abort_signal.clone()).await?\n    };\n    config\n        .write()\n        .after_chat_completion(&input, &output, &tool_results)?;\n    if !tool_results.is_empty() {\n        ask(\n            config,\n            abort_signal,\n            input.merge_tool_results(output, tool_results),\n            false,\n        )\n        .await\n    } else {\n        Config::maybe_autoname_session(config.clone());\n        Config::maybe_compress_session(config.clone());\n        Ok(())\n    }\n}\n\nfn unknown_command() -> Result<()> {\n    bail!(r#\"Unknown command. Type \".help\" for additional help.\"#);\n}\n\nfn dump_repl_help() {\n    let head = REPL_COMMANDS\n        .iter()\n        .map(|cmd| format!(\"{:<24} {}\", cmd.name, cmd.description))\n        .collect::<Vec<String>>()\n        .join(\"\\n\");\n    println!(\n        r###\"{head}\n\nType ::: to start multi-line editing, type ::: to finish it.\nPress Ctrl+O to open an editor for editing the input buffer.\nPress Ctrl+C to cancel the response, Ctrl+D to exit the REPL.\"###,\n    );\n}\n\nfn parse_command(line: &str) -> Option<(&str, Option<&str>)> {\n    match COMMAND_RE.captures(line) {\n        Ok(Some(captures)) => {\n            let cmd = captures.get(1)?.as_str();\n            let args = line[captures[0].len()..].trim();\n            let args = if args.is_empty() { None } else { Some(args) };\n            Some((cmd, args))\n        }\n        _ => None,\n    }\n}\n\nfn split_first_arg(args: Option<&str>) -> Option<(&str, Option<&str>)> {\n    args.map(|v| match v.split_once(' ') {\n        Some((subcmd, args)) => (subcmd, Some(args.trim())),\n        None => (v, None),\n    })\n}\n\npub fn split_args_text(line: &str, is_win: bool) -> (Vec<String>, &str) {\n    let mut words = Vec::new();\n    let mut word = String::new();\n    let mut unbalance: Option<char> = None;\n    let mut prev_char: Option<char> = None;\n    let mut text_starts_at = None;\n    let unquote_word = |word: &str| {\n        if ((word.starts_with('\"') && word.ends_with('\"'))\n            || (word.starts_with('\\'') && word.ends_with('\\'')))\n            && word.len() >= 2\n        {\n            word[1..word.len() - 1].to_string()\n        } else {\n            word.to_string()\n        }\n    };\n    let chars: Vec<char> = line.chars().collect();\n\n    for (i, char) in chars.iter().cloned().enumerate() {\n        match unbalance {\n            Some(ub_char) if ub_char == char => {\n                word.push(char);\n                unbalance = None;\n            }\n            Some(_) => {\n                word.push(char);\n            }\n            None => match char {\n                ' ' | '\\t' | '\\r' | '\\n' => {\n                    if char == '\\r' && chars.get(i + 1) == Some(&'\\n') {\n                        continue;\n                    }\n                    if let Some('\\\\') = prev_char.filter(|_| !is_win) {\n                        word.push(char);\n                    } else if !word.is_empty() {\n                        if word == \"--\" {\n                            word.clear();\n                            text_starts_at = Some(i + 1);\n                            break;\n                        }\n                        words.push(unquote_word(&word));\n                        word.clear();\n                    }\n                }\n                '\\'' | '\"' | '`' => {\n                    word.push(char);\n                    unbalance = Some(char);\n                }\n                '\\\\' => {\n                    if is_win || prev_char.map(|c| c == '\\\\').unwrap_or_default() {\n                        word.push(char);\n                    }\n                }\n                _ => {\n                    word.push(char);\n                }\n            },\n        }\n        prev_char = Some(char);\n    }\n\n    if !word.is_empty() && word != \"--\" {\n        words.push(unquote_word(&word));\n    }\n    let text = match text_starts_at {\n        Some(start) => &line[start..],\n        None => \"\",\n    };\n\n    (words, text)\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_process_command_line() {\n        assert_eq!(parse_command(\" .\"), Some((\".\", None)));\n        assert_eq!(parse_command(\" .role\"), Some((\".role\", None)));\n        assert_eq!(parse_command(\" .role  \"), Some((\".role\", None)));\n        assert_eq!(\n            parse_command(\" .set dry_run true\"),\n            Some((\".set\", Some(\"dry_run true\")))\n        );\n        assert_eq!(\n            parse_command(\" .set dry_run true  \"),\n            Some((\".set\", Some(\"dry_run true\")))\n        );\n        assert_eq!(\n            parse_command(\".prompt \\nabc\\n\"),\n            Some((\".prompt\", Some(\"abc\")))\n        );\n    }\n\n    #[test]\n    fn test_split_args_text() {\n        assert_eq!(split_args_text(\"\", false), (vec![], \"\"));\n        assert_eq!(\n            split_args_text(\"file.txt\", false),\n            (vec![\"file.txt\".into()], \"\")\n        );\n        assert_eq!(\n            split_args_text(\"file.txt --\", false),\n            (vec![\"file.txt\".into()], \"\")\n        );\n        assert_eq!(\n            split_args_text(\"file.txt -- hello\", false),\n            (vec![\"file.txt\".into()], \"hello\")\n        );\n        assert_eq!(\n            split_args_text(\"file.txt -- \\thello\", false),\n            (vec![\"file.txt\".into()], \"\\thello\")\n        );\n        assert_eq!(\n            split_args_text(\"file.txt --\\nhello\", false),\n            (vec![\"file.txt\".into()], \"hello\")\n        );\n        assert_eq!(\n            split_args_text(\"file.txt --\\r\\nhello\", false),\n            (vec![\"file.txt\".into()], \"hello\")\n        );\n        assert_eq!(\n            split_args_text(\"file.txt --\\rhello\", false),\n            (vec![\"file.txt\".into()], \"hello\")\n        );\n        assert_eq!(\n            split_args_text(r#\"file1.txt 'file2.txt' \"file3.txt\"\"#, false),\n            (\n                vec![\"file1.txt\".into(), \"file2.txt\".into(), \"file3.txt\".into()],\n                \"\"\n            )\n        );\n        assert_eq!(\n            split_args_text(r#\"./file1.txt 'file1 - Copy.txt' file\\ 2.txt\"#, false),\n            (\n                vec![\n                    \"./file1.txt\".into(),\n                    \"file1 - Copy.txt\".into(),\n                    \"file 2.txt\".into()\n                ],\n                \"\"\n            )\n        );\n        assert_eq!(\n            split_args_text(r#\".\\file.txt C:\\dir\\file.txt\"#, true),\n            (vec![\".\\\\file.txt\".into(), \"C:\\\\dir\\\\file.txt\".into()], \"\")\n        );\n    }\n}\n"
  },
  {
    "path": "src/repl/prompt.rs",
    "content": "use crate::config::GlobalConfig;\n\nuse reedline::{Prompt, PromptHistorySearch, PromptHistorySearchStatus};\nuse std::borrow::Cow;\n\n#[derive(Clone)]\npub struct ReplPrompt {\n    config: GlobalConfig,\n}\n\nimpl ReplPrompt {\n    pub fn new(config: &GlobalConfig) -> Self {\n        Self {\n            config: config.clone(),\n        }\n    }\n}\n\nimpl Prompt for ReplPrompt {\n    fn render_prompt_left(&self) -> Cow<'_, str> {\n        Cow::Owned(self.config.read().render_prompt_left())\n    }\n\n    fn render_prompt_right(&self) -> Cow<'_, str> {\n        Cow::Owned(self.config.read().render_prompt_right())\n    }\n\n    fn render_prompt_indicator(&self, _prompt_mode: reedline::PromptEditMode) -> Cow<'_, str> {\n        Cow::Borrowed(\"\")\n    }\n\n    fn render_prompt_multiline_indicator(&self) -> Cow<'_, str> {\n        Cow::Borrowed(\"... \")\n    }\n\n    fn render_prompt_history_search_indicator(\n        &self,\n        history_search: PromptHistorySearch,\n    ) -> Cow<'_, str> {\n        let prefix = match history_search.status {\n            PromptHistorySearchStatus::Passing => \"\",\n            PromptHistorySearchStatus::Failing => \"failing \",\n        };\n        // NOTE: magic strings, given there is logic on how these compose I am not sure if it\n        // is worth extracting in to static constant\n        Cow::Owned(format!(\n            \"({}reverse-search: {}) \",\n            prefix, history_search.term\n        ))\n    }\n}\n"
  },
  {
    "path": "src/serve.rs",
    "content": "use crate::{client::*, config::*, function::*, rag::*, utils::*};\n\nuse anyhow::{anyhow, bail, Result};\nuse bytes::Bytes;\nuse chrono::{Timelike, Utc};\nuse futures_util::StreamExt;\nuse http::{Method, Response, StatusCode};\nuse http_body_util::{combinators::BoxBody, BodyExt, Full, StreamBody};\nuse hyper::{\n    body::{Frame, Incoming},\n    service::service_fn,\n};\nuse hyper_util::rt::{TokioExecutor, TokioIo};\nuse parking_lot::RwLock;\nuse serde::Deserialize;\nuse serde_json::{json, Value};\nuse std::{\n    convert::Infallible,\n    net::IpAddr,\n    sync::{\n        atomic::{AtomicBool, Ordering},\n        Arc,\n    },\n};\nuse tokio::{\n    net::TcpListener,\n    sync::{\n        mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender},\n        oneshot,\n    },\n};\nuse tokio_graceful::Shutdown;\nuse tokio_stream::wrappers::UnboundedReceiverStream;\n\nconst DEFAULT_MODEL_NAME: &str = \"default\";\nconst PLAYGROUND_HTML: &[u8] = include_bytes!(\"../assets/playground.html\");\nconst ARENA_HTML: &[u8] = include_bytes!(\"../assets/arena.html\");\n\ntype AppResponse = Response<BoxBody<Bytes, Infallible>>;\n\npub async fn run(config: GlobalConfig, addr: Option<String>) -> Result<()> {\n    let addr = match addr {\n        Some(addr) => {\n            if let Ok(port) = addr.parse::<u16>() {\n                format!(\"127.0.0.1:{port}\")\n            } else if let Ok(ip) = addr.parse::<IpAddr>() {\n                format!(\"{ip}:8000\")\n            } else {\n                addr\n            }\n        }\n        None => config.read().serve_addr(),\n    };\n    let server = Arc::new(Server::new(&config));\n    let listener = TcpListener::bind(&addr).await?;\n    let stop_server = server.run(listener).await?;\n    println!(\"Chat Completions API: http://{addr}/v1/chat/completions\");\n    println!(\"Embeddings API:       http://{addr}/v1/embeddings\");\n    println!(\"Rerank API:           http://{addr}/v1/rerank\");\n    println!(\"LLM Playground:       http://{addr}/playground\");\n    println!(\"LLM Arena:            http://{addr}/arena?num=2\");\n    shutdown_signal().await;\n    let _ = stop_server.send(());\n    Ok(())\n}\n\nstruct Server {\n    config: Config,\n    models: Vec<Value>,\n    roles: Vec<Role>,\n    rags: Vec<String>,\n}\n\nimpl Server {\n    fn new(config: &GlobalConfig) -> Self {\n        let mut config = config.read().clone();\n        config.functions = Functions::default();\n        let mut models = list_all_models(&config);\n        let mut default_model = config.model.clone();\n        default_model.data_mut().name = DEFAULT_MODEL_NAME.into();\n        models.insert(0, &default_model);\n        let models: Vec<Value> = models\n            .into_iter()\n            .enumerate()\n            .map(|(i, model)| {\n                let id = if i == 0 {\n                    DEFAULT_MODEL_NAME.into()\n                } else {\n                    model.id()\n                };\n                let mut value = json!(model.data());\n                if let Some(value_obj) = value.as_object_mut() {\n                    value_obj.insert(\"id\".into(), id.into());\n                    value_obj.insert(\"object\".into(), \"model\".into());\n                    value_obj.insert(\"owned_by\".into(), model.client_name().into());\n                    value_obj.remove(\"name\");\n                }\n                value\n            })\n            .collect();\n        Self {\n            config,\n            models,\n            roles: Config::all_roles(),\n            rags: Config::list_rags(),\n        }\n    }\n\n    async fn run(self: Arc<Self>, listener: TcpListener) -> Result<oneshot::Sender<()>> {\n        let (tx, rx) = oneshot::channel();\n        tokio::spawn(async move {\n            let shutdown = Shutdown::new(async { rx.await.unwrap_or_default() });\n            let guard = shutdown.guard_weak();\n\n            loop {\n                tokio::select! {\n                    res = listener.accept() => {\n                        let Ok((cnx, _)) = res else {\n                            continue;\n                        };\n\n                        let stream = TokioIo::new(cnx);\n                        let server = self.clone();\n                        shutdown.spawn_task(async move {\n                            let hyper_service = service_fn(move |request: hyper::Request<Incoming>| {\n                                server.clone().handle(request)\n                            });\n                            let _ = hyper_util::server::conn::auto::Builder::new(TokioExecutor::new())\n                                .serve_connection_with_upgrades(stream, hyper_service)\n                                .await;\n                        });\n                    }\n                    _ = guard.cancelled() => {\n                        break;\n                    }\n                }\n            }\n        });\n        Ok(tx)\n    }\n\n    async fn handle(\n        self: Arc<Self>,\n        req: hyper::Request<Incoming>,\n    ) -> std::result::Result<AppResponse, hyper::Error> {\n        let method = req.method().clone();\n        let uri = req.uri().clone();\n        let path = uri.path();\n\n        if method == Method::OPTIONS {\n            let mut res = Response::default();\n            *res.status_mut() = StatusCode::NO_CONTENT;\n            set_cors_header(&mut res);\n            return Ok(res);\n        }\n\n        let mut status = StatusCode::OK;\n        let res = if path == \"/v1/chat/completions\" {\n            self.chat_completions(req).await\n        } else if path == \"/v1/embeddings\" {\n            self.embeddings(req).await\n        } else if path == \"/v1/rerank\" {\n            self.rerank(req).await\n        } else if path == \"/v1/models\" {\n            self.list_models()\n        } else if path == \"/v1/roles\" {\n            self.list_roles()\n        } else if path == \"/v1/rags\" {\n            self.list_rags()\n        } else if path == \"/v1/rags/search\" {\n            self.search_rag(req).await\n        } else if path == \"/playground\" || path == \"/playground.html\" {\n            self.playground_page()\n        } else if path == \"/arena\" || path == \"/arena.html\" {\n            self.arena_page()\n        } else {\n            status = StatusCode::NOT_FOUND;\n            Err(anyhow!(\"Not Found\"))\n        };\n        let mut res = match res {\n            Ok(res) => {\n                info!(\"{method} {uri} {}\", status.as_u16());\n                res\n            }\n            Err(err) => {\n                if status == StatusCode::OK {\n                    status = StatusCode::BAD_REQUEST;\n                }\n                error!(\"{method} {uri} {} {err}\", status.as_u16());\n                ret_err(err)\n            }\n        };\n        *res.status_mut() = status;\n        set_cors_header(&mut res);\n        Ok(res)\n    }\n\n    fn playground_page(&self) -> Result<AppResponse> {\n        let res = Response::builder()\n            .header(\"Content-Type\", \"text/html; charset=utf-8\")\n            .body(Full::new(Bytes::from(PLAYGROUND_HTML)).boxed())?;\n        Ok(res)\n    }\n\n    fn arena_page(&self) -> Result<AppResponse> {\n        let res = Response::builder()\n            .header(\"Content-Type\", \"text/html; charset=utf-8\")\n            .body(Full::new(Bytes::from(ARENA_HTML)).boxed())?;\n        Ok(res)\n    }\n\n    fn list_models(&self) -> Result<AppResponse> {\n        let data = json!({ \"data\": self.models });\n        let res = Response::builder()\n            .header(\"Content-Type\", \"application/json; charset=utf-8\")\n            .body(Full::new(Bytes::from(data.to_string())).boxed())?;\n        Ok(res)\n    }\n\n    fn list_roles(&self) -> Result<AppResponse> {\n        let data = json!({ \"data\": self.roles });\n        let res = Response::builder()\n            .header(\"Content-Type\", \"application/json; charset=utf-8\")\n            .body(Full::new(Bytes::from(data.to_string())).boxed())?;\n        Ok(res)\n    }\n\n    fn list_rags(&self) -> Result<AppResponse> {\n        let data = json!({ \"data\": self.rags });\n        let res = Response::builder()\n            .header(\"Content-Type\", \"application/json; charset=utf-8\")\n            .body(Full::new(Bytes::from(data.to_string())).boxed())?;\n        Ok(res)\n    }\n\n    async fn search_rag(&self, req: hyper::Request<Incoming>) -> Result<AppResponse> {\n        let req_body = req.collect().await?.to_bytes();\n        let req_body: Value = serde_json::from_slice(&req_body)\n            .map_err(|err| anyhow!(\"Invalid request json, {err}\"))?;\n\n        debug!(\"search rag request: {req_body}\");\n        let SearchRagReqBody { name, input } = serde_json::from_value(req_body)\n            .map_err(|err| anyhow!(\"Invalid request body, {err}\"))?;\n\n        let config = Arc::new(RwLock::new(self.config.clone()));\n\n        let abort_signal = create_abort_signal();\n\n        let rag_path = config.read().rag_file(&name);\n        let rag = Rag::load(&config, &name, &rag_path)?;\n\n        let rag_result = Config::search_rag(&config, &rag, &input, abort_signal).await?;\n\n        let data = json!({ \"data\": rag_result });\n        let res = Response::builder()\n            .header(\"Content-Type\", \"application/json; charset=utf-8\")\n            .body(Full::new(Bytes::from(data.to_string())).boxed())?;\n        Ok(res)\n    }\n\n    async fn chat_completions(&self, req: hyper::Request<Incoming>) -> Result<AppResponse> {\n        let req_body = req.collect().await?.to_bytes();\n        let req_body: Value = serde_json::from_slice(&req_body)\n            .map_err(|err| anyhow!(\"Invalid request json, {err}\"))?;\n\n        debug!(\"chat completions request: {req_body}\");\n        let req_body = serde_json::from_value(req_body)\n            .map_err(|err| anyhow!(\"Invalid request body, {err}\"))?;\n\n        let ChatCompletionsReqBody {\n            model,\n            messages,\n            temperature,\n            top_p,\n            max_tokens,\n            stream,\n            tools,\n        } = req_body;\n\n        let mut messages =\n            parse_messages(messages).map_err(|err| anyhow!(\"Invalid request body, {err}\"))?;\n\n        let functions = parse_tools(tools).map_err(|err| anyhow!(\"Invalid request body, {err}\"))?;\n\n        let config = self.config.clone();\n\n        let default_model = config.model.clone();\n\n        let config = Arc::new(RwLock::new(config));\n\n        let (model_name, change) = if model == DEFAULT_MODEL_NAME {\n            (default_model.id(), true)\n        } else if default_model.id() == model {\n            (model, false)\n        } else {\n            (model, true)\n        };\n\n        if change {\n            config.write().set_model(&model_name)?;\n        }\n\n        let mut client = init_client(&config, None)?;\n        if max_tokens.is_some() {\n            client.model_mut().set_max_tokens(max_tokens, true);\n        }\n        let abort_signal = create_abort_signal();\n        let http_client = client.build_client()?;\n\n        let completion_id = generate_completion_id();\n        let created = Utc::now().timestamp();\n\n        patch_messages(&mut messages, client.model());\n\n        let data: ChatCompletionsData = ChatCompletionsData {\n            messages,\n            temperature,\n            top_p,\n            functions,\n            stream,\n        };\n\n        if stream {\n            let (tx, mut rx) = unbounded_channel();\n            tokio::spawn(async move {\n                let is_first = Arc::new(AtomicBool::new(true));\n                let (sse_tx, sse_rx) = unbounded_channel();\n                let mut handler = SseHandler::new(sse_tx, abort_signal);\n                async fn map_event(\n                    mut sse_rx: UnboundedReceiver<SseEvent>,\n                    tx: &UnboundedSender<ResEvent>,\n                    is_first: Arc<AtomicBool>,\n                ) {\n                    while let Some(reply_event) = sse_rx.recv().await {\n                        if is_first.load(Ordering::SeqCst) {\n                            let _ = tx.send(ResEvent::First(None));\n                            is_first.store(false, Ordering::SeqCst)\n                        }\n                        match reply_event {\n                            SseEvent::Text(text) => {\n                                let _ = tx.send(ResEvent::Text(text));\n                            }\n                            SseEvent::Done => {\n                                let _ = tx.send(ResEvent::Done);\n                                sse_rx.close();\n                            }\n                        }\n                    }\n                }\n                async fn chat_completions(\n                    client: &dyn Client,\n                    http_client: &reqwest::Client,\n                    handler: &mut SseHandler,\n                    mut data: ChatCompletionsData,\n                    tx: &UnboundedSender<ResEvent>,\n                    is_first: Arc<AtomicBool>,\n                ) {\n                    if client.model().no_stream() {\n                        data.stream = false;\n                        let ret = client.chat_completions_inner(http_client, data).await;\n                        match ret {\n                            Ok(output) => {\n                                let ChatCompletionsOutput {\n                                    text, tool_calls, ..\n                                } = output;\n                                let _ = tx.send(ResEvent::First(None));\n                                is_first.store(false, Ordering::SeqCst);\n                                let _ = tx.send(ResEvent::Text(text));\n                                if !tool_calls.is_empty() {\n                                    let _ = tx.send(ResEvent::ToolCalls(tool_calls));\n                                }\n                            }\n                            Err(err) => {\n                                let _ = tx.send(ResEvent::First(Some(format!(\"{err:?}\"))));\n                                is_first.store(false, Ordering::SeqCst)\n                            }\n                        };\n                    } else {\n                        let ret = client\n                            .chat_completions_streaming_inner(http_client, handler, data)\n                            .await;\n                        let first = match ret {\n                            Ok(()) => None,\n                            Err(err) => Some(format!(\"{err:?}\")),\n                        };\n                        if is_first.load(Ordering::SeqCst) {\n                            let _ = tx.send(ResEvent::First(first));\n                            is_first.store(false, Ordering::SeqCst)\n                        }\n                        let tool_calls = handler.tool_calls().to_vec();\n                        if !tool_calls.is_empty() {\n                            let _ = tx.send(ResEvent::ToolCalls(tool_calls));\n                        }\n                    }\n                    handler.done();\n                }\n                tokio::join!(\n                    map_event(sse_rx, &tx, is_first.clone()),\n                    chat_completions(\n                        client.as_ref(),\n                        &http_client,\n                        &mut handler,\n                        data,\n                        &tx,\n                        is_first\n                    ),\n                );\n            });\n\n            let first_event = rx.recv().await;\n\n            if let Some(ResEvent::First(Some(err))) = first_event {\n                bail!(\"{err}\");\n            }\n\n            let shared: Arc<(String, String, i64, AtomicBool)> =\n                Arc::new((completion_id, model_name, created, AtomicBool::new(false)));\n            let stream = UnboundedReceiverStream::new(rx);\n            let stream = stream.filter_map(move |res_event| {\n                let shared = shared.clone();\n                async move {\n                    let (completion_id, model, created, has_tool_calls) = shared.as_ref();\n                    match res_event {\n                        ResEvent::Text(text) => {\n                            Some(Ok(create_text_frame(completion_id, model, *created, &text)))\n                        }\n                        ResEvent::ToolCalls(tool_calls) => {\n                            has_tool_calls.store(true, Ordering::SeqCst);\n                            Some(Ok(create_tool_calls_frame(\n                                completion_id,\n                                model,\n                                *created,\n                                &tool_calls,\n                            )))\n                        }\n                        ResEvent::Done => Some(Ok(create_done_frame(\n                            completion_id,\n                            model,\n                            *created,\n                            has_tool_calls.load(Ordering::SeqCst),\n                        ))),\n                        _ => None,\n                    }\n                }\n            });\n            let res = Response::builder()\n                .status(StatusCode::OK)\n                .header(\"Content-Type\", \"text/event-stream\")\n                .header(\"Cache-Control\", \"no-cache\")\n                .header(\"Connection\", \"keep-alive\")\n                .body(BodyExt::boxed(StreamBody::new(stream)))?;\n            Ok(res)\n        } else {\n            let output = client.chat_completions_inner(&http_client, data).await?;\n            let res = Response::builder()\n                .header(\"Content-Type\", \"application/json\")\n                .body(\n                    Full::new(ret_non_stream(\n                        &completion_id,\n                        &model_name,\n                        created,\n                        &output,\n                    ))\n                    .boxed(),\n                )?;\n            Ok(res)\n        }\n    }\n\n    async fn embeddings(&self, req: hyper::Request<Incoming>) -> Result<AppResponse> {\n        let req_body = req.collect().await?.to_bytes();\n        let req_body: Value = serde_json::from_slice(&req_body)\n            .map_err(|err| anyhow!(\"Invalid request json, {err}\"))?;\n\n        debug!(\"embeddings request: {req_body}\");\n        let req_body = serde_json::from_value(req_body)\n            .map_err(|err| anyhow!(\"Invalid request body, {err}\"))?;\n\n        let EmbeddingsReqBody {\n            input,\n            model: embedding_model_id,\n        } = req_body;\n\n        let config = Arc::new(RwLock::new(self.config.clone()));\n\n        let embedding_model =\n            Model::retrieve_model(&config.read(), &embedding_model_id, ModelType::Embedding)?;\n\n        let texts = match input {\n            EmbeddingsReqBodyInput::Single(v) => vec![v],\n            EmbeddingsReqBodyInput::Multiple(v) => v,\n        };\n        let client = init_client(&config, Some(embedding_model))?;\n        let data = client\n            .embeddings(&EmbeddingsData {\n                query: false,\n                texts,\n            })\n            .await?;\n        let data: Vec<_> = data\n            .into_iter()\n            .enumerate()\n            .map(|(i, v)| {\n                json!({\n                        \"object\": \"embedding\",\n                        \"embedding\": v,\n                        \"index\": i,\n                })\n            })\n            .collect();\n        let output = json!({\n            \"object\": \"list\",\n            \"data\": data,\n            \"model\": embedding_model_id,\n            \"usage\": {\n                \"prompt_tokens\": 0,\n                \"total_tokens\": 0,\n            }\n        });\n        let res = Response::builder()\n            .header(\"Content-Type\", \"application/json\")\n            .body(Full::new(Bytes::from(output.to_string())).boxed())?;\n        Ok(res)\n    }\n\n    async fn rerank(&self, req: hyper::Request<Incoming>) -> Result<AppResponse> {\n        let req_body = req.collect().await?.to_bytes();\n        let req_body: Value = serde_json::from_slice(&req_body)\n            .map_err(|err| anyhow!(\"Invalid request json, {err}\"))?;\n\n        debug!(\"rerank request: {req_body}\");\n        let req_body = serde_json::from_value(req_body)\n            .map_err(|err| anyhow!(\"Invalid request body, {err}\"))?;\n\n        let RerankReqBody {\n            model: reranker_model_id,\n            documents,\n            query,\n            top_n,\n        } = req_body;\n\n        let top_n = top_n.unwrap_or(documents.len());\n\n        let config = Arc::new(RwLock::new(self.config.clone()));\n\n        let reranker_model =\n            Model::retrieve_model(&config.read(), &reranker_model_id, ModelType::Reranker)?;\n\n        let client = init_client(&config, Some(reranker_model))?;\n        let data = client\n            .rerank(&RerankData {\n                query,\n                documents: documents.clone(),\n                top_n,\n            })\n            .await?;\n\n        let results: Vec<_> = data\n            .into_iter()\n            .map(|v| {\n                json!({\n                    \"index\": v.index,\n                    \"relevance_score\": v.relevance_score,\n                    \"document\": documents.get(v.index).map(|v| json!(v)).unwrap_or_default(),\n                })\n            })\n            .collect();\n        let output = json!({\n            \"id\": uuid::Uuid::new_v4().to_string(),\n            \"results\": results,\n        });\n        let res = Response::builder()\n            .header(\"Content-Type\", \"application/json\")\n            .body(Full::new(Bytes::from(output.to_string())).boxed())?;\n        Ok(res)\n    }\n}\n\n#[derive(Debug, Deserialize)]\nstruct SearchRagReqBody {\n    name: String,\n    input: String,\n}\n\n#[derive(Debug, Deserialize)]\nstruct ChatCompletionsReqBody {\n    model: String,\n    messages: Vec<Value>,\n    temperature: Option<f64>,\n    top_p: Option<f64>,\n    max_tokens: Option<isize>,\n    #[serde(default)]\n    stream: bool,\n    tools: Option<Vec<Value>>,\n}\n\n#[derive(Debug, Deserialize)]\nstruct EmbeddingsReqBody {\n    input: EmbeddingsReqBodyInput,\n    model: String,\n}\n\n#[derive(Debug, Deserialize)]\n#[serde(untagged)]\nenum EmbeddingsReqBodyInput {\n    Single(String),\n    Multiple(Vec<String>),\n}\n\n#[derive(Debug, Deserialize)]\nstruct RerankReqBody {\n    documents: Vec<String>,\n    query: String,\n    model: String,\n    top_n: Option<usize>,\n}\n\n#[derive(Debug)]\nenum ResEvent {\n    First(Option<String>),\n    Text(String),\n    ToolCalls(Vec<ToolCall>),\n    Done,\n}\n\nasync fn shutdown_signal() {\n    tokio::signal::ctrl_c()\n        .await\n        .expect(\"Failed to install CTRL+C signal handler\")\n}\n\nfn generate_completion_id() -> String {\n    let random_id = chrono::Utc::now().nanosecond();\n    format!(\"chatcmpl-{random_id}\")\n}\n\nfn set_cors_header(res: &mut AppResponse) {\n    res.headers_mut().insert(\n        hyper::header::ACCESS_CONTROL_ALLOW_ORIGIN,\n        hyper::header::HeaderValue::from_static(\"*\"),\n    );\n    res.headers_mut().insert(\n        hyper::header::ACCESS_CONTROL_ALLOW_METHODS,\n        hyper::header::HeaderValue::from_static(\"GET,POST,PUT,PATCH,DELETE\"),\n    );\n    res.headers_mut().insert(\n        hyper::header::ACCESS_CONTROL_ALLOW_HEADERS,\n        hyper::header::HeaderValue::from_static(\"Content-Type,Authorization\"),\n    );\n}\n\nfn create_text_frame(id: &str, model: &str, created: i64, content: &str) -> Frame<Bytes> {\n    let delta = if content.is_empty() {\n        json!({ \"role\": \"assistant\", \"content\": content })\n    } else {\n        json!({ \"content\": content })\n    };\n    let choice = json!({\n        \"index\": 0,\n        \"delta\": delta,\n        \"finish_reason\": null,\n    });\n    let value = build_chat_completion_chunk_json(id, model, created, &choice);\n    Frame::data(Bytes::from(format!(\"data: {value}\\n\\n\")))\n}\n\nfn create_tool_calls_frame(\n    id: &str,\n    model: &str,\n    created: i64,\n    tool_calls: &[ToolCall],\n) -> Frame<Bytes> {\n    let chunks = tool_calls\n        .iter()\n        .enumerate()\n        .flat_map(|(i, call)| {\n            let choice1 = json!({\n              \"index\": 0,\n              \"delta\": {\n                \"role\": \"assistant\",\n                \"content\": null,\n                \"tool_calls\": [\n                  {\n                    \"index\": i,\n                    \"id\": call.id,\n                    \"type\": \"function\",\n                    \"function\": {\n                      \"name\": call.name,\n                      \"arguments\": \"\"\n                    }\n                  }\n                ]\n              },\n              \"finish_reason\": null\n            });\n            let choice2 = json!({\n              \"index\": 0,\n              \"delta\": {\n                \"tool_calls\": [\n                  {\n                    \"index\": i,\n                    \"function\": {\n                      \"arguments\": call.arguments.to_string(),\n                    }\n                  }\n                ]\n              },\n              \"finish_reason\": null\n            });\n            vec![\n                build_chat_completion_chunk_json(id, model, created, &choice1),\n                build_chat_completion_chunk_json(id, model, created, &choice2),\n            ]\n        })\n        .map(|v| format!(\"data: {v}\\n\\n\"))\n        .collect::<Vec<String>>()\n        .join(\"\");\n    Frame::data(Bytes::from(chunks))\n}\n\nfn create_done_frame(id: &str, model: &str, created: i64, has_tool_calls: bool) -> Frame<Bytes> {\n    let finish_reason = if has_tool_calls { \"tool_calls\" } else { \"stop\" };\n    let choice = json!({\n        \"index\": 0,\n        \"delta\": {},\n        \"finish_reason\": finish_reason,\n    });\n    let value = build_chat_completion_chunk_json(id, model, created, &choice);\n    Frame::data(Bytes::from(format!(\"data: {value}\\n\\ndata: [DONE]\\n\\n\")))\n}\n\nfn build_chat_completion_chunk_json(id: &str, model: &str, created: i64, choice: &Value) -> Value {\n    json!({\n        \"id\": id,\n        \"object\": \"chat.completion.chunk\",\n        \"created\": created,\n        \"model\": model,\n        \"choices\": [choice],\n    })\n}\n\nfn ret_non_stream(id: &str, model: &str, created: i64, output: &ChatCompletionsOutput) -> Bytes {\n    let id = output.id.as_deref().unwrap_or(id);\n    let input_tokens = output.input_tokens.unwrap_or_default();\n    let output_tokens = output.output_tokens.unwrap_or_default();\n    let total_tokens = input_tokens + output_tokens;\n    let choice = if output.tool_calls.is_empty() {\n        json!({\n            \"index\": 0,\n            \"message\": {\n                \"role\": \"assistant\",\n                \"content\": output.text,\n            },\n            \"logprobs\": null,\n            \"finish_reason\": \"stop\",\n        })\n    } else {\n        let content = if output.text.is_empty() {\n            Value::Null\n        } else {\n            output.text.clone().into()\n        };\n        let tool_calls: Vec<_> = output\n            .tool_calls\n            .iter()\n            .map(|call| {\n                json!({\n                    \"id\": call.id,\n                    \"type\": \"function\",\n                    \"function\": {\n                        \"name\": call.name,\n                        \"arguments\": call.arguments.to_string(),\n                    }\n                })\n            })\n            .collect();\n        json!({\n            \"index\": 0,\n            \"message\": {\n                \"role\": \"assistant\",\n                \"content\": content,\n                \"tool_calls\": tool_calls,\n            },\n            \"logprobs\": null,\n            \"finish_reason\": \"tool_calls\",\n        })\n    };\n    let res_body = json!({\n        \"id\": id,\n        \"object\": \"chat.completion\",\n        \"created\": created,\n        \"model\": model,\n        \"choices\": [choice],\n        \"usage\": {\n            \"prompt_tokens\": input_tokens,\n            \"completion_tokens\": output_tokens,\n            \"total_tokens\": total_tokens,\n        },\n    });\n    Bytes::from(res_body.to_string())\n}\n\nfn ret_err<T: std::fmt::Display>(err: T) -> AppResponse {\n    let data = json!({\n        \"error\": {\n            \"message\": err.to_string(),\n            \"type\": \"invalid_request_error\",\n        },\n    });\n    Response::builder()\n        .header(\"Content-Type\", \"application/json\")\n        .body(Full::new(Bytes::from(data.to_string())).boxed())\n        .unwrap()\n}\n\nfn parse_messages(message: Vec<Value>) -> Result<Vec<Message>> {\n    let mut output = vec![];\n    let mut tool_results = None;\n    for (i, message) in message.into_iter().enumerate() {\n        let err = || anyhow!(\"Failed to parse '.messages[{i}]'\");\n        let role = message[\"role\"].as_str().ok_or_else(err)?;\n        let content = match message.get(\"content\") {\n            Some(value) => {\n                if let Some(value) = value.as_str() {\n                    MessageContent::Text(value.to_string())\n                } else if value.is_array() {\n                    let value = serde_json::from_value(value.clone()).map_err(|_| err())?;\n                    MessageContent::Array(value)\n                } else if value.is_null() {\n                    MessageContent::Text(String::new())\n                } else {\n                    return Err(err());\n                }\n            }\n            None => MessageContent::Text(String::new()),\n        };\n        match role {\n            \"system\" | \"user\" => {\n                let role = match role {\n                    \"system\" => MessageRole::System,\n                    \"user\" => MessageRole::User,\n                    _ => unreachable!(),\n                };\n                output.push(Message::new(role, content))\n            }\n            \"assistant\" => {\n                let role = MessageRole::Assistant;\n                match message[\"tool_calls\"].as_array() {\n                    Some(tool_calls) => {\n                        if tool_results.is_some() {\n                            return Err(err());\n                        }\n                        let mut list = vec![];\n                        for tool_call in tool_calls {\n                            if let (id, Some(name), Some(arguments)) = (\n                                tool_call[\"id\"].as_str().map(|v| v.to_string()),\n                                tool_call[\"function\"][\"name\"].as_str(),\n                                tool_call[\"function\"][\"arguments\"].as_str(),\n                            ) {\n                                let arguments =\n                                    serde_json::from_str(arguments).map_err(|_| err())?;\n                                list.push((id, name.to_string(), arguments));\n                            } else {\n                                return Err(err());\n                            }\n                        }\n                        tool_results = Some((content.to_text(), list, vec![]));\n                    }\n                    None => output.push(Message::new(role, content)),\n                }\n            }\n            \"tool\" => match tool_results.take() {\n                Some((text, tool_calls, mut tool_values)) => {\n                    let tool_call_id = message[\"tool_call_id\"].as_str().map(|v| v.to_string());\n                    let content = content.to_text();\n                    let value: Value = serde_json::from_str(&content)\n                        .ok()\n                        .unwrap_or_else(|| content.into());\n\n                    tool_values.push((value, tool_call_id));\n\n                    if tool_calls.len() == tool_values.len() {\n                        let mut list = vec![];\n                        for ((id, name, arguments), (value, tool_call_id)) in\n                            tool_calls.into_iter().zip(tool_values.into_iter())\n                        {\n                            if id != tool_call_id {\n                                return Err(err());\n                            }\n                            list.push(ToolResult::new(ToolCall::new(name, arguments, id), value))\n                        }\n                        output.push(Message::new(\n                            MessageRole::Assistant,\n                            MessageContent::ToolCalls(MessageContentToolCalls::new(list, text)),\n                        ));\n                        tool_results = None;\n                    } else {\n                        tool_results = Some((text, tool_calls, tool_values));\n                    }\n                }\n                None => return Err(err()),\n            },\n            _ => {\n                return Err(err());\n            }\n        }\n    }\n\n    if tool_results.is_some() {\n        bail!(\"Invalid messages\");\n    }\n\n    Ok(output)\n}\n\nfn parse_tools(tools: Option<Vec<Value>>) -> Result<Option<Vec<FunctionDeclaration>>> {\n    let tools = match tools {\n        Some(v) => v,\n        None => return Ok(None),\n    };\n    let mut functions = vec![];\n    for (i, tool) in tools.into_iter().enumerate() {\n        if let (Some(\"function\"), Some(function)) = (\n            tool[\"type\"].as_str(),\n            tool[\"function\"]\n                .as_object()\n                .and_then(|v| serde_json::from_value(json!(v)).ok()),\n        ) {\n            functions.push(function);\n        } else {\n            bail!(\"Failed to parse '.tools[{i}]'\")\n        }\n    }\n    Ok(Some(functions))\n}\n"
  },
  {
    "path": "src/utils/abort_signal.rs",
    "content": "use anyhow::Result;\nuse crossterm::event::{self, Event, KeyCode, KeyModifiers};\nuse std::{\n    sync::{\n        atomic::{AtomicBool, Ordering},\n        Arc,\n    },\n    time::Duration,\n};\n\npub type AbortSignal = Arc<AbortSignalInner>;\n\npub struct AbortSignalInner {\n    ctrlc: AtomicBool,\n    ctrld: AtomicBool,\n}\n\npub fn create_abort_signal() -> AbortSignal {\n    AbortSignalInner::new()\n}\n\nimpl AbortSignalInner {\n    pub fn new() -> AbortSignal {\n        Arc::new(Self {\n            ctrlc: AtomicBool::new(false),\n            ctrld: AtomicBool::new(false),\n        })\n    }\n\n    pub fn aborted(&self) -> bool {\n        if self.aborted_ctrlc() {\n            return true;\n        }\n        if self.aborted_ctrld() {\n            return true;\n        }\n        false\n    }\n\n    pub fn aborted_ctrlc(&self) -> bool {\n        self.ctrlc.load(Ordering::SeqCst)\n    }\n\n    pub fn aborted_ctrld(&self) -> bool {\n        self.ctrld.load(Ordering::SeqCst)\n    }\n\n    pub fn reset(&self) {\n        self.ctrlc.store(false, Ordering::SeqCst);\n        self.ctrld.store(false, Ordering::SeqCst);\n    }\n\n    pub fn set_ctrlc(&self) {\n        self.ctrlc.store(true, Ordering::SeqCst);\n    }\n\n    pub fn set_ctrld(&self) {\n        self.ctrld.store(true, Ordering::SeqCst);\n    }\n}\n\npub async fn wait_abort_signal(abort_signal: &AbortSignal) {\n    loop {\n        if abort_signal.aborted() {\n            break;\n        }\n        tokio::time::sleep(std::time::Duration::from_millis(25)).await;\n    }\n}\n\npub fn poll_abort_signal(abort_signal: &AbortSignal) -> Result<bool> {\n    if crossterm::event::poll(Duration::from_millis(25))? {\n        if let Event::Key(key) = event::read()? {\n            match key.code {\n                KeyCode::Char('c') if key.modifiers == KeyModifiers::CONTROL => {\n                    abort_signal.set_ctrlc();\n                    return Ok(true);\n                }\n                KeyCode::Char('d') if key.modifiers == KeyModifiers::CONTROL => {\n                    abort_signal.set_ctrld();\n                    return Ok(true);\n                }\n                _ => {}\n            }\n        }\n    }\n    Ok(false)\n}\n"
  },
  {
    "path": "src/utils/clipboard.rs",
    "content": "use anyhow::Context;\n\n#[cfg(not(any(target_os = \"android\", target_os = \"emscripten\")))]\nmod internal {\n    use arboard::Clipboard;\n    use base64::{engine::general_purpose::STANDARD, Engine as _};\n    use std::sync::{LazyLock, Mutex};\n\n    static CLIPBOARD: LazyLock<Mutex<Option<Clipboard>>> =\n        LazyLock::new(|| Mutex::new(Clipboard::new().ok()));\n\n    pub fn set_text(text: &str) -> anyhow::Result<()> {\n        let mut clipboard = CLIPBOARD.lock().unwrap();\n        match clipboard.as_mut() {\n            Some(clipboard) => {\n                clipboard.set_text(text)?;\n                #[cfg(target_os = \"linux\")]\n                std::thread::sleep(std::time::Duration::from_millis(50));\n                Ok(())\n            }\n            None => set_text_osc52(text),\n        }\n    }\n\n    /// Attempts to set text to clipboard with OSC52 escape sequence\n    /// Works in many modern terminals, including over SSH.\n    fn set_text_osc52(text: &str) -> anyhow::Result<()> {\n        let encoded = STANDARD.encode(text);\n        let seq = format!(\"\\x1b]52;c;{encoded}\\x07\");\n        if let Err(e) = std::io::Write::write_all(&mut std::io::stdout(), seq.as_bytes()) {\n            return Err(anyhow::anyhow!(\"Failed to send OSC52 sequence\").context(e));\n        }\n        if let Err(e) = std::io::Write::flush(&mut std::io::stdout()) {\n            return Err(anyhow::anyhow!(\"Failed to flush OSC52 sequence\").context(e));\n        }\n        Ok(())\n    }\n}\n\n#[cfg(any(target_os = \"android\", target_os = \"emscripten\"))]\nmod internal {\n    pub fn set_text(_text: &str) -> anyhow::Result<()> {\n        Err(anyhow::anyhow!(\"No clipboard available\"))\n    }\n}\n\npub fn set_text(text: &str) -> anyhow::Result<()> {\n    internal::set_text(text).context(\"Failed to copy\")\n}\n"
  },
  {
    "path": "src/utils/command.rs",
    "content": "use super::*;\n\nuse std::{\n    collections::HashMap,\n    env,\n    ffi::OsStr,\n    fs::OpenOptions,\n    io::{self, Write},\n    path::{Path, PathBuf},\n    process::Command,\n};\n\nuse anyhow::{anyhow, bail, Context, Result};\nuse dirs::home_dir;\nuse std::sync::LazyLock;\n\npub static SHELL: LazyLock<Shell> = LazyLock::new(detect_shell);\n\npub struct Shell {\n    pub name: String,\n    pub cmd: String,\n    pub arg: String,\n}\n\nimpl Shell {\n    pub fn new(name: &str, cmd: &str, arg: &str) -> Self {\n        Self {\n            name: name.to_string(),\n            cmd: cmd.to_string(),\n            arg: arg.to_string(),\n        }\n    }\n}\n\npub fn detect_shell() -> Shell {\n    let cmd = env::var(get_env_name(\"shell\")).ok().or_else(|| {\n        if cfg!(windows) {\n            if let Ok(ps_module_path) = env::var(\"PSModulePath\") {\n                let ps_module_path = ps_module_path.to_lowercase();\n                if ps_module_path.starts_with(r\"c:\\users\") {\n                    if ps_module_path.contains(r\"\\powershell\\7\\\") {\n                        return Some(\"pwsh.exe\".to_string());\n                    } else {\n                        return Some(\"powershell.exe\".to_string());\n                    }\n                }\n            }\n            None\n        } else {\n            env::var(\"SHELL\").ok()\n        }\n    });\n    let name = cmd\n        .as_ref()\n        .and_then(|v| Path::new(v).file_stem().and_then(|v| v.to_str()))\n        .map(|v| {\n            if v == \"nu\" {\n                \"nushell\".into()\n            } else {\n                v.to_lowercase()\n            }\n        });\n    let (cmd, name) = match (cmd.as_deref(), name.as_deref()) {\n        (Some(cmd), Some(name)) => (cmd, name),\n        _ => {\n            if cfg!(windows) {\n                (\"cmd.exe\", \"cmd\")\n            } else {\n                (\"/bin/sh\", \"sh\")\n            }\n        }\n    };\n    let shell_arg = match name {\n        \"powershel\" => \"-Command\",\n        \"cmd\" => \"/C\",\n        _ => \"-c\",\n    };\n    Shell::new(name, cmd, shell_arg)\n}\n\npub fn run_command<T: AsRef<OsStr>>(\n    cmd: &str,\n    args: &[T],\n    envs: Option<HashMap<String, String>>,\n) -> Result<i32> {\n    let status = Command::new(cmd)\n        .args(args.iter())\n        .envs(envs.unwrap_or_default())\n        .status()?;\n    Ok(status.code().unwrap_or_default())\n}\n\npub fn run_command_with_output<T: AsRef<OsStr>>(\n    cmd: &str,\n    args: &[T],\n    envs: Option<HashMap<String, String>>,\n) -> Result<(bool, String, String)> {\n    let output = Command::new(cmd)\n        .args(args.iter())\n        .envs(envs.unwrap_or_default())\n        .output()?;\n    let status = output.status;\n    let stdout = std::str::from_utf8(&output.stdout).context(\"Invalid UTF-8 in stdout\")?;\n    let stderr = std::str::from_utf8(&output.stderr).context(\"Invalid UTF-8 in stderr\")?;\n    Ok((status.success(), stdout.to_string(), stderr.to_string()))\n}\n\npub fn run_loader_command(path: &str, extension: &str, loader_command: &str) -> Result<String> {\n    let cmd_args = shell_words::split(loader_command)\n        .with_context(|| anyhow!(\"Invalid document loader '{extension}': `{loader_command}`\"))?;\n    let mut use_stdout = true;\n    let outpath = temp_file(\"-output-\", \"\").display().to_string();\n    let cmd_args: Vec<_> = cmd_args\n        .into_iter()\n        .map(|mut v| {\n            if v.contains(\"$1\") {\n                v = v.replace(\"$1\", path);\n            }\n            if v.contains(\"$2\") {\n                use_stdout = false;\n                v = v.replace(\"$2\", &outpath);\n            }\n            v\n        })\n        .collect();\n    let cmd_eval = shell_words::join(&cmd_args);\n    debug!(\"run `{cmd_eval}`\");\n    let (cmd, args) = cmd_args.split_at(1);\n    let cmd = &cmd[0];\n    if use_stdout {\n        let (success, stdout, stderr) =\n            run_command_with_output(cmd, args, None).with_context(|| {\n                format!(\"Unable to run `{cmd_eval}`, Perhaps '{cmd}' is not installed?\")\n            })?;\n        if !success {\n            let err = if !stderr.is_empty() {\n                stderr\n            } else {\n                format!(\"The command `{cmd_eval}` exited with non-zero.\")\n            };\n            bail!(\"{err}\")\n        }\n        Ok(stdout)\n    } else {\n        let status = run_command(cmd, args, None).with_context(|| {\n            format!(\"Unable to run `{cmd_eval}`, Perhaps '{cmd}' is not installed?\")\n        })?;\n        if status != 0 {\n            bail!(\"The command `{cmd_eval}` exited with non-zero.\")\n        }\n        let contents = std::fs::read_to_string(&outpath)\n            .context(\"Failed to read file generated by the loader\")?;\n        Ok(contents)\n    }\n}\n\npub fn edit_file(editor: &str, path: &Path) -> Result<()> {\n    let mut child = Command::new(editor).arg(path).spawn()?;\n    child.wait()?;\n    Ok(())\n}\n\npub fn append_to_shell_history(shell: &str, command: &str, exit_code: i32) -> io::Result<()> {\n    if let Some(history_file) = get_history_file(shell) {\n        let command = command.replace('\\n', \" \");\n        let now = now_timestamp();\n        let history_txt = if shell == \"fish\" {\n            format!(\"- cmd: {command}\\n  when: {now}\")\n        } else if shell == \"zsh\" {\n            format!(\": {now}:{exit_code};{command}\",)\n        } else {\n            command\n        };\n        let mut file = OpenOptions::new()\n            .create(true)\n            .append(true)\n            .open(&history_file)?;\n        writeln!(file, \"{history_txt}\")?;\n    }\n    Ok(())\n}\n\nfn get_history_file(shell: &str) -> Option<PathBuf> {\n    match shell {\n        \"bash\" | \"sh\" => env::var(\"HISTFILE\")\n            .ok()\n            .map(PathBuf::from)\n            .or(Some(home_dir()?.join(\".bash_history\"))),\n        \"zsh\" => env::var(\"HISTFILE\")\n            .ok()\n            .map(PathBuf::from)\n            .or(Some(home_dir()?.join(\".zsh_history\"))),\n        \"nushell\" => Some(dirs::config_dir()?.join(\"nushell\").join(\"history.txt\")),\n        \"fish\" => Some(\n            home_dir()?\n                .join(\".local\")\n                .join(\"share\")\n                .join(\"fish\")\n                .join(\"fish_history\"),\n        ),\n        \"powershell\" | \"pwsh\" => {\n            #[cfg(not(windows))]\n            {\n                Some(\n                    home_dir()?\n                        .join(\".local\")\n                        .join(\"share\")\n                        .join(\"powershell\")\n                        .join(\"PSReadLine\")\n                        .join(\"ConsoleHost_history.txt\"),\n                )\n            }\n            #[cfg(windows)]\n            {\n                Some(\n                    dirs::data_dir()?\n                        .join(\"Microsoft\")\n                        .join(\"Windows\")\n                        .join(\"PowerShell\")\n                        .join(\"PSReadLine\")\n                        .join(\"ConsoleHost_history.txt\"),\n                )\n            }\n        }\n        \"ksh\" => Some(home_dir()?.join(\".ksh_history\")),\n        \"tcsh\" => Some(home_dir()?.join(\".history\")),\n        _ => None,\n    }\n}\n"
  },
  {
    "path": "src/utils/crypto.rs",
    "content": "use base64::{engine::general_purpose::STANDARD, Engine};\nuse hmac::{Hmac, Mac};\nuse sha2::{Digest, Sha256};\n\npub fn sha256(input: &str) -> String {\n    let mut hasher = Sha256::new();\n    hasher.update(input);\n    format!(\"{:x}\", hasher.finalize())\n}\n\npub fn hmac_sha256(key: &[u8], msg: &str) -> Vec<u8> {\n    let mut mac = Hmac::<Sha256>::new_from_slice(key).expect(\"HMAC can take key of any size\");\n    mac.update(msg.as_bytes());\n    mac.finalize().into_bytes().to_vec()\n}\n\npub fn hex_encode(bytes: &[u8]) -> String {\n    bytes\n        .iter()\n        .fold(String::new(), |acc, b| acc + &format!(\"{b:02x}\"))\n}\n\npub fn encode_uri(uri: &str) -> String {\n    uri.split('/')\n        .map(|v| urlencoding::encode(v))\n        .collect::<Vec<_>>()\n        .join(\"/\")\n}\n\npub fn base64_encode<T: AsRef<[u8]>>(input: T) -> String {\n    STANDARD.encode(input)\n}\npub fn base64_decode<T: AsRef<[u8]>>(input: T) -> Result<Vec<u8>, base64::DecodeError> {\n    STANDARD.decode(input)\n}\n"
  },
  {
    "path": "src/utils/html_to_md.rs",
    "content": "use std::{cell::RefCell, rc::Rc};\n\nuse html_to_markdown::{markdown, TagHandler};\n\npub fn html_to_md(html: &str) -> String {\n    let mut handlers: Vec<TagHandler> = vec![\n        Rc::new(RefCell::new(markdown::ParagraphHandler)),\n        Rc::new(RefCell::new(markdown::HeadingHandler)),\n        Rc::new(RefCell::new(markdown::ListHandler)),\n        Rc::new(RefCell::new(markdown::TableHandler::new())),\n        Rc::new(RefCell::new(markdown::StyledTextHandler)),\n        Rc::new(RefCell::new(markdown::CodeHandler)),\n        Rc::new(RefCell::new(markdown::WebpageChromeRemover)),\n    ];\n\n    html_to_markdown::convert_html_to_markdown(html.as_bytes(), &mut handlers)\n        .unwrap_or_else(|_| html.to_string())\n}\n"
  },
  {
    "path": "src/utils/input.rs",
    "content": "use anyhow::Result;\nuse crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};\nuse crossterm::terminal::{disable_raw_mode, enable_raw_mode};\nuse std::io::{stdout, Write};\n\n/// Reads a single character from stdin without requiring Enter\n/// Returns the character if it's one of the valid options, or the default if Enter is pressed\npub fn read_single_key(valid_chars: &[char], default: char, prompt: &str) -> Result<char> {\n    print!(\"{prompt}\");\n    stdout().flush()?;\n\n    enable_raw_mode()?;\n\n    let result = loop {\n        if let Ok(Event::Key(KeyEvent {\n            code, modifiers, ..\n        })) = event::read()\n        {\n            match code {\n                KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => {\n                    break Err(anyhow::anyhow!(\"Interrupted\"));\n                }\n                KeyCode::Char(c) => {\n                    if valid_chars.contains(&c) {\n                        break Ok(c);\n                    }\n                    // Invalid character, continue loop\n                }\n                KeyCode::Enter => {\n                    break Ok(default);\n                }\n                _ => {\n                    // Other keys are ignored, continue loop\n                }\n            }\n        }\n    };\n\n    disable_raw_mode()?;\n\n    // Print the chosen character and newline for clean output\n    if let Ok(chosen) = &result {\n        println!(\"{chosen}\");\n    }\n\n    result\n}\n"
  },
  {
    "path": "src/utils/loader.rs",
    "content": "use super::*;\n\nuse anyhow::{anyhow, Context, Result};\nuse indexmap::IndexMap;\nuse serde::{Deserialize, Serialize};\nuse std::collections::HashMap;\n\npub const EXTENSION_METADATA: &str = \"__extension__\";\n\npub type DocumentMetadata = IndexMap<String, String>;\n\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct LoadedDocument {\n    pub path: String,\n    pub contents: String,\n    #[serde(default)]\n    pub metadata: DocumentMetadata,\n}\n\nimpl LoadedDocument {\n    pub fn new(path: String, contents: String, metadata: DocumentMetadata) -> Self {\n        Self {\n            path,\n            contents,\n            metadata,\n        }\n    }\n}\n\npub async fn load_recursive_url(\n    loaders: &HashMap<String, String>,\n    path: &str,\n) -> Result<Vec<LoadedDocument>> {\n    let extension = RECURSIVE_URL_LOADER;\n    let pages: Vec<Page> = match loaders.get(extension) {\n        Some(loader_command) => {\n            let contents = run_loader_command(path, extension, loader_command)?;\n            serde_json::from_str(&contents).context(r#\"The crawler response is invalid. It should follow the JSON format: `[{\"path\":\"...\", \"text\":\"...\"}]`.\"#)?\n        }\n        None => {\n            let options = CrawlOptions::preset(path);\n            crawl_website(path, options).await?\n        }\n    };\n    let output = pages\n        .into_iter()\n        .map(|v| {\n            let Page { path, text } = v;\n            let mut metadata: DocumentMetadata = Default::default();\n            metadata.insert(EXTENSION_METADATA.into(), \"md\".into());\n            LoadedDocument::new(path, text, metadata)\n        })\n        .collect();\n    Ok(output)\n}\n\npub async fn load_file(loaders: &HashMap<String, String>, path: &str) -> Result<LoadedDocument> {\n    let extension = get_patch_extension(path).unwrap_or_else(|| DEFAULT_EXTENSION.into());\n    match loaders.get(&extension) {\n        Some(loader_command) => load_with_command(path, &extension, loader_command),\n        None => load_plain(path, &extension).await,\n    }\n}\n\npub async fn load_url(loaders: &HashMap<String, String>, path: &str) -> Result<LoadedDocument> {\n    let (contents, extension) = fetch_with_loaders(loaders, path, false).await?;\n    let mut metadata: DocumentMetadata = Default::default();\n    metadata.insert(EXTENSION_METADATA.into(), extension);\n    Ok(LoadedDocument::new(path.into(), contents, metadata))\n}\n\nasync fn load_plain(path: &str, extension: &str) -> Result<LoadedDocument> {\n    let contents = tokio::fs::read_to_string(path).await?;\n    let mut metadata: DocumentMetadata = Default::default();\n    metadata.insert(EXTENSION_METADATA.into(), extension.to_string());\n    Ok(LoadedDocument::new(path.into(), contents, metadata))\n}\n\nfn load_with_command(path: &str, extension: &str, loader_command: &str) -> Result<LoadedDocument> {\n    let contents = run_loader_command(path, extension, loader_command)?;\n    let mut metadata: DocumentMetadata = Default::default();\n    metadata.insert(EXTENSION_METADATA.into(), DEFAULT_EXTENSION.to_string());\n    Ok(LoadedDocument::new(path.into(), contents, metadata))\n}\n\npub fn is_loader_protocol(loaders: &HashMap<String, String>, path: &str) -> bool {\n    match path.split_once(':') {\n        Some((protocol, _)) => loaders.contains_key(protocol),\n        None => false,\n    }\n}\n\npub fn load_protocol_path(\n    loaders: &HashMap<String, String>,\n    path: &str,\n) -> Result<Vec<LoadedDocument>> {\n    let (protocol, loader_command, new_path) = path\n        .split_once(':')\n        .and_then(|(protocol, path)| {\n            let loader_command = loaders.get(protocol)?;\n            Some((protocol, loader_command, path))\n        })\n        .ok_or_else(|| anyhow!(\"No document loader for '{}'\", path))?;\n    let contents = run_loader_command(new_path, protocol, loader_command)?;\n    let output = if let Ok(list) = serde_json::from_str::<Vec<LoadedDocument>>(&contents) {\n        list.into_iter()\n            .map(|mut v| {\n                if v.path.starts_with(path) {\n                } else if v.path.starts_with(new_path) {\n                    v.path = format!(\"{}:{}\", protocol, v.path);\n                } else {\n                    v.path = format!(\"{}/{}\", path, v.path);\n                }\n                v\n            })\n            .collect()\n    } else {\n        vec![LoadedDocument::new(\n            path.into(),\n            contents,\n            Default::default(),\n        )]\n    };\n    Ok(output)\n}\n"
  },
  {
    "path": "src/utils/mod.rs",
    "content": "mod abort_signal;\nmod clipboard;\nmod command;\nmod crypto;\nmod html_to_md;\nmod input;\nmod loader;\nmod path;\nmod render_prompt;\nmod request;\nmod spinner;\nmod variables;\n\npub use self::abort_signal::*;\npub use self::clipboard::set_text;\npub use self::command::*;\npub use self::crypto::*;\npub use self::html_to_md::*;\npub use self::input::*;\npub use self::loader::*;\npub use self::path::*;\npub use self::render_prompt::render_prompt;\npub use self::request::*;\npub use self::spinner::*;\npub use self::variables::*;\n\nuse anyhow::{Context, Result};\nuse fancy_regex::Regex;\nuse fuzzy_matcher::{skim::SkimMatcherV2, FuzzyMatcher};\nuse is_terminal::IsTerminal;\nuse std::borrow::Cow;\nuse std::sync::LazyLock;\nuse std::{env, path::PathBuf, process};\nuse unicode_segmentation::UnicodeSegmentation;\n\npub static CODE_BLOCK_RE: LazyLock<Regex> =\n    LazyLock::new(|| Regex::new(r\"(?ms)```\\w*(.*)```\").unwrap());\npub static THINK_TAG_RE: LazyLock<Regex> =\n    LazyLock::new(|| Regex::new(r\"(?s)^\\s*<think>.*?</think>(\\s*|$)\").unwrap());\npub static IS_STDOUT_TERMINAL: LazyLock<bool> = LazyLock::new(|| std::io::stdout().is_terminal());\npub static NO_COLOR: LazyLock<bool> = LazyLock::new(|| {\n    env::var(\"NO_COLOR\")\n        .ok()\n        .and_then(|v| parse_bool(&v))\n        .unwrap_or_default()\n        || !*IS_STDOUT_TERMINAL\n});\n\npub fn now() -> String {\n    chrono::Local::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, false)\n}\n\npub fn now_timestamp() -> i64 {\n    chrono::Local::now().timestamp()\n}\n\npub fn get_env_name(key: &str) -> String {\n    format!(\"{}_{key}\", env!(\"CARGO_CRATE_NAME\"),).to_ascii_uppercase()\n}\n\npub fn normalize_env_name(value: &str) -> String {\n    value.replace('-', \"_\").to_ascii_uppercase()\n}\n\npub fn parse_bool(value: &str) -> Option<bool> {\n    match value {\n        \"1\" | \"true\" => Some(true),\n        \"0\" | \"false\" => Some(false),\n        _ => None,\n    }\n}\n\npub fn estimate_token_length(text: &str) -> usize {\n    let words: Vec<&str> = text.unicode_words().collect();\n    let mut output: f32 = 0.0;\n    for word in words {\n        if word.is_ascii() {\n            output += 1.3;\n        } else {\n            let count = word.chars().count();\n            if count == 1 {\n                output += 1.0\n            } else {\n                output += (count as f32) * 0.5;\n            }\n        }\n    }\n    output.ceil() as usize\n}\n\npub fn strip_think_tag(text: &str) -> Cow<'_, str> {\n    THINK_TAG_RE.replace_all(text, \"\")\n}\n\npub fn extract_code_block(text: &str) -> &str {\n    CODE_BLOCK_RE\n        .captures(text)\n        .ok()\n        .and_then(|v| v?.get(1).map(|v| v.as_str().trim()))\n        .unwrap_or(text)\n}\n\npub fn convert_option_string(value: &str) -> Option<String> {\n    if value.is_empty() {\n        None\n    } else {\n        Some(value.to_string())\n    }\n}\n\npub fn fuzzy_filter<T, F>(values: Vec<T>, get: F, pattern: &str) -> Vec<T>\nwhere\n    F: Fn(&T) -> &str,\n{\n    let matcher = SkimMatcherV2::default();\n    let mut list: Vec<(T, i64)> = values\n        .into_iter()\n        .filter_map(|v| {\n            let score = matcher.fuzzy_match(get(&v), pattern)?;\n            Some((v, score))\n        })\n        .collect();\n    list.sort_unstable_by(|a, b| b.1.cmp(&a.1));\n    list.into_iter().map(|(v, _)| v).collect()\n}\n\npub fn pretty_error(err: &anyhow::Error) -> String {\n    let mut output = vec![];\n    output.push(format!(\"Error: {err}\"));\n    let causes: Vec<_> = err.chain().skip(1).collect();\n    let causes_len = causes.len();\n    if causes_len > 0 {\n        output.push(\"\\nCaused by:\".to_string());\n        if causes_len == 1 {\n            output.push(format!(\"    {}\", indent_text(causes[0], 4).trim()));\n        } else {\n            for (i, cause) in causes.into_iter().enumerate() {\n                output.push(format!(\"{i:5}: {}\", indent_text(cause, 7).trim()));\n            }\n        }\n    }\n    output.join(\"\\n\")\n}\n\npub fn indent_text<T: ToString>(s: T, size: usize) -> String {\n    let indent_str = \" \".repeat(size);\n    s.to_string()\n        .split('\\n')\n        .map(|line| format!(\"{indent_str}{line}\"))\n        .collect::<Vec<String>>()\n        .join(\"\\n\")\n}\n\npub fn error_text(input: &str) -> String {\n    color_text(input, nu_ansi_term::Color::Red)\n}\n\npub fn warning_text(input: &str) -> String {\n    color_text(input, nu_ansi_term::Color::Yellow)\n}\n\npub fn color_text(input: &str, color: nu_ansi_term::Color) -> String {\n    if *NO_COLOR {\n        return input.to_string();\n    }\n    nu_ansi_term::Style::new()\n        .fg(color)\n        .paint(input)\n        .to_string()\n}\n\npub fn dimmed_text(input: &str) -> String {\n    if *NO_COLOR {\n        return input.to_string();\n    }\n    nu_ansi_term::Style::new().dimmed().paint(input).to_string()\n}\n\npub fn multiline_text(input: &str) -> String {\n    input\n        .split('\\n')\n        .enumerate()\n        .map(|(i, v)| {\n            if i == 0 {\n                v.to_string()\n            } else {\n                format!(\".. {v}\")\n            }\n        })\n        .collect::<Vec<String>>()\n        .join(\"\\n\")\n}\n\npub fn temp_file(prefix: &str, suffix: &str) -> PathBuf {\n    env::temp_dir().join(format!(\n        \"{}-{}{prefix}{}{suffix}\",\n        env!(\"CARGO_CRATE_NAME\").to_lowercase(),\n        process::id(),\n        uuid::Uuid::new_v4()\n    ))\n}\n\npub fn is_url(path: &str) -> bool {\n    path.starts_with(\"http://\") || path.starts_with(\"https://\")\n}\n\npub fn set_proxy(\n    mut builder: reqwest::ClientBuilder,\n    proxy: &str,\n) -> Result<reqwest::ClientBuilder> {\n    builder = builder.no_proxy();\n    if !proxy.is_empty() && proxy != \"-\" {\n        builder = builder\n            .proxy(reqwest::Proxy::all(proxy).with_context(|| format!(\"Invalid proxy `{proxy}`\"))?);\n    };\n    Ok(builder)\n}\n\npub fn decode_bin<T: serde::de::DeserializeOwned>(data: &[u8]) -> Result<T> {\n    let (v, _) = bincode::serde::decode_from_slice(data, bincode::config::legacy())?;\n    Ok(v)\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    #[cfg(not(target_os = \"windows\"))]\n    fn test_safe_join_path() {\n        assert_eq!(\n            safe_join_path(\"/home/user/dir1\", \"files/file1\"),\n            Some(PathBuf::from(\"/home/user/dir1/files/file1\"))\n        );\n        assert!(safe_join_path(\"/home/user/dir1\", \"/files/file1\").is_none());\n        assert!(safe_join_path(\"/home/user/dir1\", \"../file1\").is_none());\n    }\n\n    #[test]\n    #[cfg(target_os = \"windows\")]\n    fn test_safe_join_path() {\n        assert_eq!(\n            safe_join_path(\"C:\\\\Users\\\\user\\\\dir1\", \"files/file1\"),\n            Some(PathBuf::from(\"C:\\\\Users\\\\user\\\\dir1\\\\files\\\\file1\"))\n        );\n        assert!(safe_join_path(\"C:\\\\Users\\\\user\\\\dir1\", \"/files/file1\").is_none());\n        assert!(safe_join_path(\"C:\\\\Users\\\\user\\\\dir1\", \"../file1\").is_none());\n    }\n}\n"
  },
  {
    "path": "src/utils/path.rs",
    "content": "use std::path::{Component, Path, PathBuf};\n\nuse anyhow::{bail, Result};\nuse indexmap::IndexSet;\nuse path_absolutize::Absolutize;\n\npub fn safe_join_path<T1: AsRef<Path>, T2: AsRef<Path>>(\n    base_path: T1,\n    sub_path: T2,\n) -> Option<PathBuf> {\n    let base_path = base_path.as_ref();\n    let sub_path = sub_path.as_ref();\n    if sub_path.is_absolute() {\n        return None;\n    }\n\n    let mut joined_path = PathBuf::from(base_path);\n\n    for component in sub_path.components() {\n        if Component::ParentDir == component {\n            return None;\n        }\n        joined_path.push(component);\n    }\n\n    if joined_path.starts_with(base_path) {\n        Some(joined_path)\n    } else {\n        None\n    }\n}\n\npub async fn expand_glob_paths<T: AsRef<str>>(\n    paths: &[T],\n    bail_non_exist: bool,\n) -> Result<IndexSet<String>> {\n    let mut new_paths = IndexSet::new();\n    for path in paths {\n        let (path_str, suffixes, current_only) = parse_glob(path.as_ref())?;\n        list_files(\n            &mut new_paths,\n            Path::new(&path_str),\n            suffixes.as_ref(),\n            current_only,\n            bail_non_exist,\n        )\n        .await?;\n    }\n    Ok(new_paths)\n}\n\npub fn list_file_names<T: AsRef<Path>>(dir: T, ext: &str) -> Vec<String> {\n    match std::fs::read_dir(dir.as_ref()) {\n        Ok(rd) => {\n            let mut names = vec![];\n            for entry in rd.flatten() {\n                let name = entry.file_name();\n                if let Some(name) = name.to_string_lossy().strip_suffix(ext) {\n                    names.push(name.to_string());\n                }\n            }\n            names.sort_unstable();\n            names\n        }\n        Err(_) => vec![],\n    }\n}\n\npub fn get_patch_extension(path: &str) -> Option<String> {\n    Path::new(&path)\n        .extension()\n        .map(|v| v.to_string_lossy().to_lowercase())\n}\n\npub fn to_absolute_path(path: &str) -> Result<String> {\n    Ok(Path::new(&path).absolutize()?.display().to_string())\n}\n\npub fn resolve_home_dir(path: &str) -> String {\n    let mut path = path.to_string();\n    if path.starts_with(\"~/\") || path.starts_with(\"~\\\\\") {\n        if let Some(home_dir) = dirs::home_dir() {\n            path.replace_range(..1, &home_dir.display().to_string());\n        }\n    }\n    path\n}\n\nfn parse_glob(path_str: &str) -> Result<(String, Option<Vec<String>>, bool)> {\n    let glob_result =\n        if let Some(start) = path_str.find(\"/**/*.\").or_else(|| path_str.find(r\"\\**\\*.\")) {\n            Some((start, 6, false))\n        } else if let Some(start) = path_str.find(\"**/*.\").or_else(|| path_str.find(r\"**\\*.\")) {\n            if start == 0 {\n                Some((start, 5, false))\n            } else {\n                None\n            }\n        } else if let Some(start) = path_str.find(\"/*.\").or_else(|| path_str.find(r\"\\*.\")) {\n            Some((start, 3, true))\n        } else if let Some(start) = path_str.find(\"*.\") {\n            if start == 0 {\n                Some((start, 2, true))\n            } else {\n                None\n            }\n        } else {\n            None\n        };\n    if let Some((start, offset, current_only)) = glob_result {\n        let mut base_path = path_str[..start].to_string();\n        if base_path.is_empty() {\n            base_path = if path_str\n                .chars()\n                .next()\n                .map(|v| v == '/')\n                .unwrap_or_default()\n            {\n                \"/\"\n            } else {\n                \".\"\n            }\n            .into();\n        }\n\n        let extensions = if let Some(curly_brace_end) = path_str[start..].find('}') {\n            let end = start + curly_brace_end;\n            let extensions_str = &path_str[start + offset..end + 1];\n            if extensions_str.starts_with('{') && extensions_str.ends_with('}') {\n                extensions_str[1..extensions_str.len() - 1]\n                    .split(',')\n                    .map(|s| s.to_string())\n                    .collect::<Vec<String>>()\n            } else {\n                bail!(\"Invalid path '{path_str}'\");\n            }\n        } else {\n            let extensions_str = &path_str[start + offset..];\n            vec![extensions_str.to_string()]\n        };\n        let extensions = if extensions.is_empty() {\n            None\n        } else {\n            Some(extensions)\n        };\n        Ok((base_path, extensions, current_only))\n    } else if path_str.ends_with(\"/**\") || path_str.ends_with(r\"\\**\") {\n        Ok((path_str[0..path_str.len() - 3].to_string(), None, false))\n    } else {\n        Ok((path_str.to_string(), None, false))\n    }\n}\n\n#[async_recursion::async_recursion]\nasync fn list_files(\n    files: &mut IndexSet<String>,\n    entry_path: &Path,\n    suffixes: Option<&Vec<String>>,\n    current_only: bool,\n    bail_non_exist: bool,\n) -> Result<()> {\n    if !entry_path.exists() {\n        if bail_non_exist {\n            bail!(\"Not found '{}'\", entry_path.display());\n        } else {\n            return Ok(());\n        }\n    }\n    if entry_path.is_dir() {\n        let mut reader = tokio::fs::read_dir(entry_path).await?;\n        while let Some(entry) = reader.next_entry().await? {\n            let path = entry.path();\n            if path.is_dir() {\n                if !current_only {\n                    list_files(files, &path, suffixes, current_only, bail_non_exist).await?;\n                }\n            } else {\n                add_file(files, suffixes, &path);\n            }\n        }\n    } else {\n        add_file(files, suffixes, entry_path);\n    }\n    Ok(())\n}\n\nfn add_file(files: &mut IndexSet<String>, suffixes: Option<&Vec<String>>, path: &Path) {\n    if is_valid_extension(suffixes, path) {\n        let path = path.display().to_string();\n        if !files.contains(&path) {\n            files.insert(path);\n        }\n    }\n}\n\nfn is_valid_extension(suffixes: Option<&Vec<String>>, path: &Path) -> bool {\n    if let Some(suffixes) = suffixes {\n        if !suffixes.is_empty() {\n            if let Some(extension) = path.extension().map(|v| v.to_string_lossy().to_string()) {\n                return suffixes.contains(&extension);\n            }\n            return false;\n        }\n    }\n    true\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_parse_glob() {\n        assert_eq!(parse_glob(\"dir\").unwrap(), (\"dir\".into(), None, false));\n        assert_eq!(parse_glob(\"dir/**\").unwrap(), (\"dir\".into(), None, false));\n        assert_eq!(\n            parse_glob(\"dir/file.md\").unwrap(),\n            (\"dir/file.md\".into(), None, false)\n        );\n        assert_eq!(\n            parse_glob(\"**/*.md\").unwrap(),\n            (\".\".into(), Some(vec![\"md\".into()]), false)\n        );\n        assert_eq!(\n            parse_glob(\"/**/*.md\").unwrap(),\n            (\"/\".into(), Some(vec![\"md\".into()]), false)\n        );\n        assert_eq!(\n            parse_glob(\"dir/**/*.md\").unwrap(),\n            (\"dir\".into(), Some(vec![\"md\".into()]), false)\n        );\n        assert_eq!(\n            parse_glob(\"dir/**/*.{md,txt}\").unwrap(),\n            (\"dir\".into(), Some(vec![\"md\".into(), \"txt\".into()]), false)\n        );\n        assert_eq!(\n            parse_glob(\"C:\\\\dir\\\\**\\\\*.{md,txt}\").unwrap(),\n            (\n                \"C:\\\\dir\".into(),\n                Some(vec![\"md\".into(), \"txt\".into()]),\n                false\n            )\n        );\n        assert_eq!(\n            parse_glob(\"*.md\").unwrap(),\n            (\".\".into(), Some(vec![\"md\".into()]), true)\n        );\n        assert_eq!(\n            parse_glob(\"/*.md\").unwrap(),\n            (\"/\".into(), Some(vec![\"md\".into()]), true)\n        );\n        assert_eq!(\n            parse_glob(\"dir/*.md\").unwrap(),\n            (\"dir\".into(), Some(vec![\"md\".into()]), true)\n        );\n        assert_eq!(\n            parse_glob(\"dir/*.{md,txt}\").unwrap(),\n            (\"dir\".into(), Some(vec![\"md\".into(), \"txt\".into()]), true)\n        );\n        assert_eq!(\n            parse_glob(\"C:\\\\dir\\\\*.{md,txt}\").unwrap(),\n            (\n                \"C:\\\\dir\".into(),\n                Some(vec![\"md\".into(), \"txt\".into()]),\n                true\n            )\n        );\n    }\n}\n"
  },
  {
    "path": "src/utils/render_prompt.rs",
    "content": "use std::collections::HashMap;\n\n/// Render REPL prompt\n///\n/// The template comprises plain text and `{...}`.\n///\n/// The syntax of `{...}`:\n/// - `{var}` - When `var` has a value, replace `var` with the value and eval `template`\n/// - `{?var <template>}` - Eval `template` when `var` is evaluated as true\n/// - `{!var <template>}` - Eval `template` when `var` is evaluated as false\npub fn render_prompt(template: &str, variables: &HashMap<&str, String>) -> String {\n    let exprs = parse_template(template);\n    eval_exprs(&exprs, variables)\n}\n\nfn parse_template(template: &str) -> Vec<Expr> {\n    let chars: Vec<char> = template.chars().collect();\n    let mut exprs = vec![];\n    let mut current = vec![];\n    let mut balances = vec![];\n    for ch in chars.iter().cloned() {\n        if !balances.is_empty() {\n            if ch == '}' {\n                balances.pop();\n                if balances.is_empty() {\n                    if !current.is_empty() {\n                        let block = parse_block(&mut current);\n                        exprs.push(block)\n                    }\n                } else {\n                    current.push(ch);\n                }\n            } else if ch == '{' {\n                balances.push(ch);\n                current.push(ch);\n            } else {\n                current.push(ch);\n            }\n        } else if ch == '{' {\n            balances.push(ch);\n            add_text(&mut exprs, &mut current);\n        } else {\n            current.push(ch)\n        }\n    }\n    add_text(&mut exprs, &mut current);\n    exprs\n}\n\nfn parse_block(current: &mut Vec<char>) -> Expr {\n    let value: String = current.drain(..).collect();\n    match value.split_once(' ') {\n        Some((name, tail)) => {\n            if let Some(name) = name.strip_prefix('?') {\n                let block_exprs = parse_template(tail);\n                Expr::Block(BlockType::Yes, name.to_string(), block_exprs)\n            } else if let Some(name) = name.strip_prefix('!') {\n                let block_exprs = parse_template(tail);\n                Expr::Block(BlockType::No, name.to_string(), block_exprs)\n            } else {\n                Expr::Text(format!(\"{{{value}}}\"))\n            }\n        }\n        None => Expr::Variable(value),\n    }\n}\n\nfn eval_exprs(exprs: &[Expr], variables: &HashMap<&str, String>) -> String {\n    let mut output = String::new();\n    for part in exprs {\n        match part {\n            Expr::Text(text) => output.push_str(text),\n            Expr::Variable(variable) => {\n                let value = variables\n                    .get(variable.as_str())\n                    .cloned()\n                    .unwrap_or_default();\n                output.push_str(&value);\n            }\n            Expr::Block(typ, variable, block_exprs) => {\n                let value = variables\n                    .get(variable.as_str())\n                    .cloned()\n                    .unwrap_or_default();\n                match typ {\n                    BlockType::Yes => {\n                        if truly(&value) {\n                            let block_output = eval_exprs(block_exprs, variables);\n                            output.push_str(&block_output)\n                        }\n                    }\n                    BlockType::No => {\n                        if !truly(&value) {\n                            let block_output = eval_exprs(block_exprs, variables);\n                            output.push_str(&block_output)\n                        }\n                    }\n                }\n            }\n        }\n    }\n    output\n}\n\nfn add_text(exprs: &mut Vec<Expr>, current: &mut Vec<char>) {\n    if current.is_empty() {\n        return;\n    }\n    let value: String = current.drain(..).collect();\n    exprs.push(Expr::Text(value));\n}\n\nfn truly(value: &str) -> bool {\n    !(value.is_empty() || value == \"0\" || value == \"false\")\n}\n\n#[derive(Debug)]\nenum Expr {\n    Text(String),\n    Variable(String),\n    Block(BlockType, String, Vec<Expr>),\n}\n\n#[derive(Debug)]\nenum BlockType {\n    Yes,\n    No,\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    macro_rules! assert_render {\n        ($template:expr, [$(($key:literal, $value:literal),)*], $expect:literal) => {\n            let data = HashMap::from([\n                $(($key, $value.into()),)*\n            ]);\n            assert_eq!(render_prompt($template, &data), $expect);\n        };\n    }\n\n    #[test]\n    fn test_render() {\n        let prompt = \"{?session {session}{?role /}}{role}{?session )}{!session >}\";\n        assert_render!(prompt, [], \">\");\n        assert_render!(prompt, [(\"role\", \"coder\"),], \"coder>\");\n        assert_render!(prompt, [(\"session\", \"temp\"),], \"temp)\");\n        assert_render!(\n            prompt,\n            [(\"session\", \"temp\"), (\"role\", \"coder\"),],\n            \"temp/coder)\"\n        );\n    }\n}\n"
  },
  {
    "path": "src/utils/request.rs",
    "content": "use super::*;\n\nuse anyhow::{anyhow, bail, Context, Result};\nuse fancy_regex::Regex;\nuse futures_util::{stream, StreamExt};\nuse http::header::CONTENT_TYPE;\nuse reqwest::Url;\nuse scraper::{Html, Selector};\nuse serde::Deserialize;\nuse serde_json::Value;\nuse std::sync::LazyLock;\nuse std::{\n    collections::{HashMap, HashSet},\n    sync::Arc,\n    time::Duration,\n};\nuse tokio::io::AsyncWriteExt;\nuse tokio::sync::Semaphore;\n\npub const URL_LOADER: &str = \"url\";\npub const RECURSIVE_URL_LOADER: &str = \"recursive_url\";\n\npub const MEDIA_URL_EXTENSION: &str = \"media_url\";\npub const DEFAULT_EXTENSION: &str = \"txt\";\n\nconst MAX_CRAWLS: usize = 5;\nconst BREAK_ON_ERROR: bool = false;\nconst USER_AGENT: &str = \"curl/8.6.0\";\n\nstatic CLIENT: LazyLock<Result<reqwest::Client>> = LazyLock::new(|| {\n    let builder = reqwest::ClientBuilder::new().timeout(Duration::from_secs(16));\n    let client = builder.build()?;\n    Ok(client)\n});\n\nstatic PRESET: LazyLock<Vec<(Regex, CrawlOptions)>> = LazyLock::new(|| {\n    vec![\n        (\n            Regex::new(r\"github.com/([^/]+)/([^/]+)/tree/([^/]+)\").unwrap(),\n            CrawlOptions {\n                exclude: vec![\"changelog\".into(), \"changes\".into(), \"license\".into()],\n                ..Default::default()\n            },\n        ),\n        (\n            Regex::new(r\"github.com/([^/]+)/([^/]+)/wiki\").unwrap(),\n            CrawlOptions {\n                exclude: vec![\"_history\".into()],\n                extract: Some(\"#wiki-body\".into()),\n                ..Default::default()\n            },\n        ),\n    ]\n});\n\nstatic EXTENSION_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r\"\\.[^.]+$\").unwrap());\nstatic GITHUB_REPO_RE: LazyLock<Regex> =\n    LazyLock::new(|| Regex::new(r\"^https://github\\.com/([^/]+)/([^/]+)/tree/([^/]+)\").unwrap());\n\npub async fn fetch(url: &str) -> Result<String> {\n    let client = match *CLIENT {\n        Ok(ref client) => client,\n        Err(ref err) => bail!(\"{err}\"),\n    };\n    let res = client.get(url).send().await?;\n    let output = res.text().await?;\n    Ok(output)\n}\n\npub async fn fetch_with_loaders(\n    loaders: &HashMap<String, String>,\n    path: &str,\n    allow_media: bool,\n) -> Result<(String, String)> {\n    if let Some(loader_command) = loaders.get(URL_LOADER) {\n        let contents = run_loader_command(path, URL_LOADER, loader_command)?;\n        return Ok((contents, DEFAULT_EXTENSION.into()));\n    }\n    let client = match *CLIENT {\n        Ok(ref client) => client,\n        Err(ref err) => bail!(\"{err}\"),\n    };\n    let mut res = client.get(path).send().await?;\n    if !res.status().is_success() {\n        bail!(\"Invalid status: {}\", res.status());\n    }\n    let content_type = res\n        .headers()\n        .get(CONTENT_TYPE)\n        .and_then(|v| v.to_str().ok())\n        .map(|v| match v.split_once(';') {\n            Some((mime, _)) => mime.trim(),\n            None => v,\n        })\n        .map(|v| v.to_string())\n        .unwrap_or_else(|| {\n            format!(\n                \"_/{}\",\n                get_patch_extension(path).unwrap_or_else(|| DEFAULT_EXTENSION.into())\n            )\n        });\n    let mut is_media = false;\n    let extension = match content_type.as_str() {\n        \"application/pdf\" => \"pdf\".into(),\n        \"application/vnd.openxmlformats-officedocument.wordprocessingml.document\" => \"docx\".into(),\n        \"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\" => \"xlsx\".into(),\n        \"application/vnd.openxmlformats-officedocument.presentationml.presentation\" => {\n            \"pptx\".into()\n        }\n        \"application/vnd.oasis.opendocument.text\" => \"odt\".into(),\n        \"application/vnd.oasis.opendocument.spreadsheet\" => \"ods\".into(),\n        \"application/vnd.oasis.opendocument.presentation\" => \"odp\".into(),\n        \"application/rtf\" => \"rtf\".into(),\n        \"text/javascript\" => \"js\".into(),\n        \"text/html\" => \"html\".into(),\n        _ => content_type\n            .rsplit_once('/')\n            .map(|(first, last)| {\n                if [\"image\", \"video\", \"audio\"].contains(&first) {\n                    is_media = true;\n                    MEDIA_URL_EXTENSION.into()\n                } else {\n                    last.to_lowercase()\n                }\n            })\n            .unwrap_or_else(|| DEFAULT_EXTENSION.into()),\n    };\n    let result = if is_media {\n        if !allow_media {\n            bail!(\"Unexpected media type\")\n        }\n        let image_bytes = res.bytes().await?;\n        let image_base64 = base64_encode(&image_bytes);\n        let contents = format!(\"data:{content_type};base64,{image_base64}\");\n        (contents, extension)\n    } else {\n        match loaders.get(&extension) {\n            Some(loader_command) => {\n                let save_path = temp_file(\"-download-\", &format!(\".{extension}\"))\n                    .display()\n                    .to_string();\n                let mut save_file = tokio::fs::File::create(&save_path).await?;\n                let mut size = 0;\n                while let Some(chunk) = res.chunk().await? {\n                    size += chunk.len();\n                    save_file.write_all(&chunk).await?;\n                }\n                let contents = if size == 0 {\n                    println!(\"{}\", warning_text(&format!(\"No content at '{path}'\")));\n                    String::new()\n                } else {\n                    run_loader_command(&save_path, &extension, loader_command)?\n                };\n                (contents, DEFAULT_EXTENSION.into())\n            }\n            None => {\n                let contents = res.text().await?;\n                if extension == \"html\" {\n                    (html_to_md(&contents), \"md\".into())\n                } else {\n                    (contents, extension)\n                }\n            }\n        }\n    };\n    Ok(result)\n}\n\npub async fn fetch_models(api_base: &str, api_key: Option<&str>) -> Result<Vec<String>> {\n    let client = match *CLIENT {\n        Ok(ref client) => client,\n        Err(ref err) => bail!(\"{err}\"),\n    };\n    let mut builder = client.get(format!(\"{}/models\", api_base.trim_end_matches('/')));\n    if let Some(api_key) = api_key {\n        builder = builder.bearer_auth(api_key);\n    }\n    let res_body: Value = builder.send().await?.json().await?;\n    let mut result: Vec<String> = res_body\n        .get(\"data\")\n        .and_then(|v| v.as_array())\n        .map(|v| {\n            v.iter()\n                .filter_map(|v| v.get(\"id\").and_then(|v| v.as_str().map(|v| v.to_string())))\n                .collect()\n        })\n        .unwrap_or_default();\n    if result.is_empty() {\n        bail!(\"No valid models\")\n    }\n    result.sort_unstable();\n    Ok(result)\n}\n\n#[derive(Debug, Clone, Default)]\npub struct CrawlOptions {\n    extract: Option<String>,\n    exclude: Vec<String>,\n    no_log: bool,\n}\n\nimpl CrawlOptions {\n    pub fn preset(start_url: &str) -> CrawlOptions {\n        for (re, options) in PRESET.iter() {\n            if let Ok(true) = re.is_match(start_url) {\n                return options.clone();\n            }\n        }\n        CrawlOptions::default()\n    }\n}\n\npub async fn crawl_website(start_url: &str, options: CrawlOptions) -> Result<Vec<Page>> {\n    let start_url = Url::parse(start_url)?;\n    let mut paths = vec![start_url.path().to_string()];\n    let normalized_start_url = normalize_start_url(&start_url);\n    if !options.no_log {\n        println!(\n            \"Start crawling url={start_url} exclude={} extract={}\",\n            options.exclude.join(\",\"),\n            options.extract.as_deref().unwrap_or_default()\n        );\n    }\n\n    if let Ok(true) = GITHUB_REPO_RE.is_match(start_url.as_str()) {\n        paths = crawl_gh_tree(&start_url, &options.exclude)\n            .await\n            .with_context(|| \"Failed to craw github repo\".to_string())?;\n    }\n\n    let semaphore = Arc::new(Semaphore::new(MAX_CRAWLS));\n    let mut result_pages = Vec::new();\n\n    let mut index = 0;\n    while index < paths.len() {\n        let batch = paths[index..std::cmp::min(index + MAX_CRAWLS, paths.len())].to_vec();\n\n        let tasks: Vec<_> = batch\n            .iter()\n            .map(|path| {\n                let options = options.clone();\n                let permit = semaphore.clone().acquire_owned(); // acquire a permit for concurrency control\n                let normalized_start_url = normalized_start_url.clone();\n                let path = path.clone();\n\n                async move {\n                    let _permit = permit.await?;\n                    let url = normalized_start_url\n                        .join(&path)\n                        .map_err(|_| anyhow!(\"Invalid crawl page at {}\", path))?;\n                    let mut page = crawl_page(&normalized_start_url, &path, options)\n                        .await\n                        .with_context(|| format!(\"Failed to crawl {}\", url.as_str()))?;\n                    page.0 = url.as_str().to_string();\n                    Ok(page)\n                }\n            })\n            .collect();\n\n        let results = stream::iter(tasks)\n            .buffer_unordered(MAX_CRAWLS)\n            .collect::<Vec<_>>()\n            .await;\n\n        let mut new_paths = Vec::new();\n\n        for res in results {\n            match res {\n                Ok((path, text, links)) => {\n                    if !options.no_log {\n                        println!(\"Crawled {path}\");\n                    }\n                    if !text.is_empty() {\n                        result_pages.push(Page { path, text });\n                    }\n                    for link in links {\n                        if !paths.iter().any(|p| match_link(p, &link)) {\n                            new_paths.push(link);\n                        }\n                    }\n                }\n                Err(err) => {\n                    if BREAK_ON_ERROR {\n                        return Err(err);\n                    } else if !options.no_log {\n                        println!(\"{}\", error_text(&pretty_error(&err)));\n                    }\n                }\n            }\n        }\n        paths.extend(new_paths);\n\n        index += batch.len();\n    }\n\n    Ok(result_pages)\n}\n\n#[derive(Debug, Deserialize)]\npub struct Page {\n    pub path: String,\n    pub text: String,\n}\n\nasync fn crawl_gh_tree(start_url: &Url, exclude: &[String]) -> Result<Vec<String>> {\n    let path_segs: Vec<&str> = start_url.path().split('/').collect();\n    if path_segs.len() < 4 {\n        bail!(\"Invalid gh tree {}\", start_url.as_str());\n    }\n    let client = match *CLIENT {\n        Ok(ref client) => client,\n        Err(ref err) => bail!(\"{err}\"),\n    };\n    let owner = path_segs[1];\n    let repo = path_segs[2];\n    let branch = path_segs[4];\n    let root_path = path_segs[5..].join(\"/\");\n\n    let url = format!(\"https://api.github.com/repos/{owner}/{repo}/git/ref/heads/{branch}\");\n\n    let res_body: Value = client\n        .get(&url)\n        .header(\"User-Agent\", USER_AGENT)\n        .header(\"Accept\", \"application/vnd.github+json\")\n        .header(\"X-GitHub-Api-Version\", \"2022-11-28\")\n        .send()\n        .await?\n        .json()\n        .await?;\n\n    let sha = res_body[\"object\"][\"sha\"]\n        .as_str()\n        .ok_or_else(|| anyhow!(\"Not found branch or tag\"))?;\n\n    let url = format!(\"https://api.github.com/repos/{owner}/{repo}/git/trees/{sha}?recursive=true\");\n\n    let res_body: Value = client\n        .get(&url)\n        .header(\"User-Agent\", USER_AGENT)\n        .header(\"Accept\", \"application/vnd.github+json\")\n        .header(\"X-GitHub-Api-Version\", \"2022-11-28\")\n        .send()\n        .await?\n        .json()\n        .await?;\n    let tree = res_body[\"tree\"]\n        .as_array()\n        .ok_or_else(|| anyhow!(\"Invalid github repo tree\"))?;\n    let paths = tree\n        .iter()\n        .flat_map(|v| {\n            let typ = v[\"type\"].as_str()?;\n            let path = v[\"path\"].as_str()?;\n            if typ == \"blob\"\n                && (path.ends_with(\".md\") || path.ends_with(\".MD\"))\n                && path.starts_with(&root_path)\n                && !should_exclude_link(path, exclude)\n            {\n                Some(format!(\n                    \"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/{path}\"\n                ))\n            } else {\n                None\n            }\n        })\n        .collect();\n\n    Ok(paths)\n}\n\nasync fn crawl_page(\n    start_url: &Url,\n    path: &str,\n    options: CrawlOptions,\n) -> Result<(String, String, Vec<String>)> {\n    let client = match *CLIENT {\n        Ok(ref client) => client,\n        Err(ref err) => bail!(\"{err}\"),\n    };\n    let location = start_url.join(path)?;\n    let response = client\n        .get(location.as_str())\n        .header(\"User-Agent\", USER_AGENT)\n        .send()\n        .await?;\n    let body = response.text().await?;\n\n    if let Ok(true) = GITHUB_REPO_RE.is_match(start_url.as_str()) {\n        return Ok((path.to_string(), body, vec![]));\n    }\n\n    let mut links = HashSet::new();\n    let document = Html::parse_document(&body);\n    let selector = Selector::parse(\"a\").map_err(|err| anyhow!(\"Invalid link selector, {}\", err))?;\n\n    for element in document.select(&selector) {\n        if let Some(href) = element.value().attr(\"href\") {\n            let href = Url::parse(href).ok().or_else(|| location.join(href).ok());\n            match href {\n                None => continue,\n                Some(href) => {\n                    if href.as_str().starts_with(location.as_str())\n                        && !should_exclude_link(href.path(), &options.exclude)\n                    {\n                        links.insert(href.path().to_string());\n                    }\n                }\n            }\n        }\n    }\n\n    let text = if let Some(selector) = &options.extract {\n        let selector = Selector::parse(selector)\n            .map_err(|err| anyhow!(\"Invalid extract selector, {}\", err))?;\n        document\n            .select(&selector)\n            .map(|v| html_to_md(&v.html()))\n            .collect::<Vec<String>>()\n            .join(\"\\n\\n\")\n    } else {\n        html_to_md(&body)\n    };\n\n    Ok((path.to_string(), text, links.into_iter().collect()))\n}\n\nfn should_exclude_link(link: &str, exclude: &[String]) -> bool {\n    if link.contains(\"#\") {\n        return true;\n    }\n    let parts: Vec<&str> = link.trim_end_matches('/').split('/').collect();\n    let name = parts.last().unwrap_or(&\"\").to_lowercase();\n\n    for exclude_name in exclude {\n        let cond = match EXTENSION_RE.is_match(exclude_name) {\n            Ok(true) => exclude_name.to_lowercase() == name.to_lowercase(),\n            _ => exclude_name.to_lowercase() == EXTENSION_RE.replace(&name, \"\").to_lowercase(),\n        };\n        if cond {\n            return true;\n        }\n    }\n    false\n}\n\nfn normalize_start_url(start_url: &Url) -> Url {\n    let mut start_url = start_url.clone();\n    start_url.set_query(None);\n    start_url.set_fragment(None);\n    let new_path = match start_url.path().rfind('/') {\n        Some(last_slash_index) => start_url.path()[..last_slash_index + 1].to_string(),\n        None => start_url.path().to_string(),\n    };\n    start_url.set_path(&new_path);\n    start_url\n}\n\nfn match_link(path: &str, link: &str) -> bool {\n    path == link\n        || path\n            == link\n                .trim_end_matches(\"/index.html\")\n                .trim_end_matches(\"/index.htm\")\n}\n"
  },
  {
    "path": "src/utils/spinner.rs",
    "content": "use super::{poll_abort_signal, wait_abort_signal, AbortSignal, IS_STDOUT_TERMINAL};\n\nuse anyhow::{bail, Result};\nuse crossterm::{cursor, queue, style, terminal};\nuse std::{\n    future::Future,\n    io::{stdout, Write},\n    time::Duration,\n};\nuse tokio::{\n    sync::{\n        mpsc::{self, UnboundedReceiver},\n        oneshot,\n    },\n    time::interval,\n};\n\n#[derive(Debug, Default)]\npub struct SpinnerInner {\n    index: usize,\n    message: String,\n}\n\nimpl SpinnerInner {\n    const DATA: [&'static str; 10] = [\"⠋\", \"⠙\", \"⠹\", \"⠸\", \"⠼\", \"⠴\", \"⠦\", \"⠧\", \"⠇\", \"⠏\"];\n\n    fn step(&mut self) -> Result<()> {\n        if !*IS_STDOUT_TERMINAL || self.message.is_empty() {\n            return Ok(());\n        }\n        let mut writer = stdout();\n        let frame = Self::DATA[self.index % Self::DATA.len()];\n        let dots = \".\".repeat((self.index / 5) % 4);\n        let line = format!(\"{frame}{}{:<3}\", self.message, dots);\n        queue!(writer, cursor::MoveToColumn(0), style::Print(line),)?;\n        if self.index == 0 {\n            queue!(writer, cursor::Hide)?;\n        }\n        writer.flush()?;\n        self.index += 1;\n        Ok(())\n    }\n\n    fn set_message(&mut self, message: String) -> Result<()> {\n        self.clear_message()?;\n        if !message.is_empty() {\n            self.message = format!(\" {message}\");\n        }\n        Ok(())\n    }\n\n    fn clear_message(&mut self) -> Result<()> {\n        if !*IS_STDOUT_TERMINAL || self.message.is_empty() {\n            return Ok(());\n        }\n        self.message.clear();\n        let mut writer = stdout();\n        queue!(\n            writer,\n            cursor::MoveToColumn(0),\n            terminal::Clear(terminal::ClearType::FromCursorDown),\n            cursor::Show\n        )?;\n        writer.flush()?;\n        Ok(())\n    }\n}\n\n#[derive(Clone)]\npub struct Spinner(mpsc::UnboundedSender<SpinnerEvent>);\n\nimpl Spinner {\n    pub fn create(message: &str) -> (Self, UnboundedReceiver<SpinnerEvent>) {\n        let (tx, spinner_rx) = mpsc::unbounded_channel();\n        let spinner = Spinner(tx);\n        let _ = spinner.set_message(message.to_string());\n        (spinner, spinner_rx)\n    }\n\n    pub fn set_message(&self, message: String) -> Result<()> {\n        self.0.send(SpinnerEvent::SetMessage(message))?;\n        std::thread::sleep(Duration::from_millis(10));\n        Ok(())\n    }\n\n    pub fn stop(&self) {\n        let _ = self.0.send(SpinnerEvent::Stop);\n        std::thread::sleep(Duration::from_millis(10));\n    }\n}\n\npub enum SpinnerEvent {\n    SetMessage(String),\n    Stop,\n}\n\npub fn spawn_spinner(message: &str) -> Spinner {\n    let (spinner, mut spinner_rx) = Spinner::create(message);\n    tokio::spawn(async move {\n        let mut spinner = SpinnerInner::default();\n        let mut interval = interval(Duration::from_millis(50));\n        loop {\n            tokio::select! {\n                evt = spinner_rx.recv() => {\n                    if let Some(evt) = evt {\n                        match evt {\n                            SpinnerEvent::SetMessage(message) => {\n                                spinner.set_message(message)?;\n                            }\n                            SpinnerEvent::Stop => {\n                                spinner.clear_message()?;\n                                break;\n                            }\n                        }\n\n                    }\n                }\n                _ = interval.tick() => {\n                    let _ = spinner.step();\n                }\n            }\n        }\n        Ok::<(), anyhow::Error>(())\n    });\n    spinner\n}\n\npub async fn abortable_run_with_spinner<F, T>(\n    task: F,\n    message: &str,\n    abort_signal: AbortSignal,\n) -> Result<T>\nwhere\n    F: Future<Output = Result<T>>,\n{\n    let (_, spinner_rx) = Spinner::create(message);\n    abortable_run_with_spinner_rx(task, spinner_rx, abort_signal).await\n}\n\npub async fn abortable_run_with_spinner_rx<F, T>(\n    task: F,\n    spinner_rx: UnboundedReceiver<SpinnerEvent>,\n    abort_signal: AbortSignal,\n) -> Result<T>\nwhere\n    F: Future<Output = Result<T>>,\n{\n    if *IS_STDOUT_TERMINAL {\n        let (done_tx, done_rx) = oneshot::channel();\n        let run_task = async {\n            tokio::select! {\n                ret = task => {\n                    let _ = done_tx.send(());\n                    ret\n                }\n                _ = tokio::signal::ctrl_c() => {\n                    abort_signal.set_ctrlc();\n                    let _ = done_tx.send(());\n                    bail!(\"Aborted!\")\n                },\n                _ = wait_abort_signal(&abort_signal) => {\n                    let _ = done_tx.send(());\n                    bail!(\"Aborted.\");\n                },\n            }\n        };\n        let (task_ret, spinner_ret) = tokio::join!(\n            run_task,\n            run_abortable_spinner(spinner_rx, done_rx, abort_signal.clone())\n        );\n        spinner_ret?;\n        task_ret\n    } else {\n        task.await\n    }\n}\n\nasync fn run_abortable_spinner(\n    mut spinner_rx: UnboundedReceiver<SpinnerEvent>,\n    mut done_rx: oneshot::Receiver<()>,\n    abort_signal: AbortSignal,\n) -> Result<()> {\n    let mut spinner = SpinnerInner::default();\n    loop {\n        if abort_signal.aborted() {\n            break;\n        }\n\n        tokio::time::sleep(Duration::from_millis(25)).await;\n\n        match done_rx.try_recv() {\n            Ok(_) | Err(oneshot::error::TryRecvError::Closed) => {\n                break;\n            }\n            _ => {}\n        }\n\n        match spinner_rx.try_recv() {\n            Ok(SpinnerEvent::SetMessage(message)) => {\n                spinner.set_message(message)?;\n            }\n            Ok(SpinnerEvent::Stop) => {\n                spinner.clear_message()?;\n            }\n            Err(_) => {}\n        }\n\n        if poll_abort_signal(&abort_signal)? {\n            break;\n        }\n\n        spinner.step()?;\n    }\n\n    spinner.clear_message()?;\n    Ok(())\n}\n"
  },
  {
    "path": "src/utils/variables.rs",
    "content": "use super::*;\nuse fancy_regex::{Captures, Regex};\nuse std::sync::LazyLock;\n\npub static RE_VARIABLE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r\"\\{\\{(\\w+)\\}\\}\").unwrap());\npub fn interpolate_variables(text: &mut String) {\n    *text = RE_VARIABLE\n        .replace_all(text, |caps: &Captures<'_>| {\n            let key = &caps[1];\n            match key {\n                \"__os__\" => env::consts::OS.to_string(),\n                \"__os_distro__\" => {\n                    let info = os_info::get();\n                    if env::consts::OS == \"linux\" {\n                        format!(\"{info} (linux)\")\n                    } else {\n                        info.to_string()\n                    }\n                }\n                \"__os_family__\" => env::consts::FAMILY.to_string(),\n                \"__arch__\" => env::consts::ARCH.to_string(),\n                \"__shell__\" => SHELL.name.clone(),\n                \"__locale__\" => sys_locale::get_locale().unwrap_or_default(),\n                \"__now__\" => now(),\n                \"__cwd__\" => env::current_dir()\n                    .map(|v| v.display().to_string())\n                    .unwrap_or_default(),\n                _ => format!(\"{{{{{key}}}}}\"),\n            }\n        })\n        .to_string();\n}\n"
  }
]