Compare commits
145 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9da927c66 | ||
|
|
7b16c5c1c3 | ||
|
|
f809bf8d7c | ||
|
|
44aea427fc | ||
|
|
8f45a57ced | ||
|
|
6a9100309c | ||
|
|
1953878e6b | ||
|
|
35761a5bf7 | ||
|
|
c7fa0cc0ee | ||
|
|
87fcf9d019 | ||
|
|
4b52bc906c | ||
|
|
517a6c5e33 | ||
|
|
636c8a35eb | ||
|
|
a50de600da | ||
|
|
e978d3cb2f | ||
|
|
2b1bdbd3d2 | ||
|
|
7d183b91d1 | ||
|
|
420f380417 | ||
|
|
6f541e2361 | ||
|
|
0737c2761e | ||
|
|
45852a0d53 | ||
|
|
0fcf8d8703 | ||
|
|
41905b006a | ||
|
|
ee3d27701b | ||
|
|
1b5c2fbdc1 | ||
|
|
a865a26093 | ||
|
|
1563e83672 | ||
|
|
cf114b37b3 | ||
|
|
b8ef2b978b | ||
|
|
7e37aee1b9 | ||
|
|
53836e556a | ||
|
|
89052bbfd9 | ||
|
|
07eb12d406 | ||
|
|
3272cd6fb2 | ||
|
|
7ab97a39ca | ||
|
|
11c5a9942e | ||
|
|
251dec454c | ||
|
|
1031dbf672 | ||
|
|
581f00b9fb | ||
|
|
e57778d2bd | ||
|
|
2d659964a7 | ||
|
|
661e7263fb | ||
|
|
1705c16762 | ||
|
|
2455117e41 | ||
|
|
3e9c110afc | ||
|
|
d9b5524c97 | ||
|
|
9b169a7d28 | ||
|
|
cb29419d75 | ||
|
|
51ca8c9948 | ||
|
|
12b938d2d1 | ||
|
|
f3d1b32ad7 | ||
|
|
3d3bd3c9f9 | ||
|
|
a25845e89e | ||
|
|
479f54b93d | ||
|
|
90ab6807ac | ||
|
|
5022f79bf5 | ||
|
|
2ba3b5a437 | ||
|
|
2819011b9d | ||
|
|
ede9065f77 | ||
|
|
5e61f5b3a3 | ||
|
|
8633e82f62 | ||
|
|
6aa3d70d57 | ||
|
|
75e29ee3fb | ||
|
|
eab7978b9f | ||
|
|
765e7c17b2 | ||
|
|
748d2b7fd4 | ||
|
|
527759dd02 | ||
|
|
3290255bad | ||
|
|
9b987395b3 | ||
|
|
4852d1fe92 | ||
|
|
8bd313f0d4 | ||
|
|
711e1099d6 | ||
|
|
91c8dd8f7d | ||
|
|
afc5572d6a | ||
|
|
73e13dcf2f | ||
|
|
2bebd109b1 | ||
|
|
7f7c58be1f | ||
|
|
47e5a464a0 | ||
|
|
85f751e514 | ||
|
|
95acad3558 | ||
|
|
304056c59a | ||
|
|
bedc0335f1 | ||
|
|
a8be8c3c1e | ||
|
|
826fa728a9 | ||
|
|
5aef8e8b2f | ||
|
|
f70f20181e | ||
|
|
fcdd4f39ee | ||
|
|
891adfec49 | ||
|
|
35ab79844b | ||
|
|
bbc122fbb1 | ||
|
|
2413c8b88b | ||
|
|
10c3c64469 | ||
|
|
b3307b404b | ||
|
|
7a0d1e830d | ||
|
|
b3bd241823 | ||
|
|
de3d8b054f | ||
|
|
0a55e276a2 | ||
|
|
1f2c65d2e6 | ||
|
|
3b5e4745f2 | ||
|
|
407bb022d9 | ||
|
|
faf92cac09 | ||
|
|
a82e9a1d1f | ||
|
|
8f87f05a08 | ||
|
|
61d18b2e13 | ||
|
|
d831c08306 | ||
|
|
c70387b0c3 | ||
|
|
38516f2e17 | ||
|
|
5481b5a763 | ||
|
|
26bc437678 | ||
|
|
ec93f1ee2a | ||
|
|
b920b6e556 | ||
|
|
7136d34843 | ||
|
|
e0b4a40dd8 | ||
|
|
691aeeb1c7 | ||
|
|
257ffae9e7 | ||
|
|
f7bf3d7b60 | ||
|
|
3a88b0d656 | ||
|
|
08c689a889 | ||
|
|
ae8e878817 | ||
|
|
edbd72ece6 | ||
|
|
22906aa2d3 | ||
|
|
99bde53ef6 | ||
|
|
b3fd8e548f | ||
|
|
062fbbb8ef | ||
|
|
2801c78ad9 | ||
|
|
f4c698ad33 | ||
|
|
bd39001417 | ||
|
|
2692d0322e | ||
|
|
8eb70f0f2c | ||
|
|
5c0a7be7a2 | ||
|
|
0a8f9fc3e5 | ||
|
|
1ac3b2e060 | ||
|
|
a3ef9fd1bf | ||
|
|
df507eb201 | ||
|
|
4dcd9eff40 | ||
|
|
ea760ce755 | ||
|
|
1528df6a55 | ||
|
|
b0fa024297 | ||
|
|
3ec203128a | ||
|
|
da97361e1b | ||
|
|
b430fe0189 | ||
|
|
f03126a9e1 | ||
|
|
7d46b926c1 | ||
|
|
6f3c048195 | ||
|
|
b47cf598b5 |
26
.github/workflows/ci.yml
vendored
Normal file
26
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
name: CI
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
push:
|
||||
branches:
|
||||
- "**"
|
||||
tags: [ "v*" ]
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
concurrency:
|
||||
group: ci-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
test-and-clippy:
|
||||
name: Unit testing and linting
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@1.93.0
|
||||
- name: Install SQLite3
|
||||
run: sudo apt-get update && sudo apt-get install -y libsqlite3-dev
|
||||
- run: cargo test --all-features
|
||||
- run: cargo clippy
|
||||
@@ -1,44 +1,49 @@
|
||||
name: CI (main and tags)
|
||||
name: Publish
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
tags: [ "v*" ]
|
||||
workflow_run:
|
||||
workflows: [ "CI" ]
|
||||
types: [ "completed" ]
|
||||
permissions:
|
||||
checks: write
|
||||
contents: write
|
||||
packages: write
|
||||
pull-requests: read
|
||||
contents: read
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
group: publish-${{ github.event.workflow_run.id || github.ref }}
|
||||
cancel-in-progress: false
|
||||
jobs:
|
||||
test-and-clippy:
|
||||
name: Unit testing and linting
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- name: Install SQLite3
|
||||
run: sudo apt-get update && sudo apt-get install -y libsqlite3-dev
|
||||
- run: cargo test --all-features
|
||||
- run: cargo clippy
|
||||
|
||||
docker-clean-metadata:
|
||||
if: |
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
(
|
||||
github.event.workflow_run.head_branch == 'main' ||
|
||||
startsWith(github.event.workflow_run.head_branch || '', 'v')
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
json: ${{ steps.meta.outputs.json }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.workflow_run.head_sha }}
|
||||
fetch-depth: 0
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable=${{ github.ref_name == 'main' }}
|
||||
type=raw,value=latest,enable=${{ github.event.workflow_run.head_branch == 'main' }}
|
||||
type=semver,pattern={{raw}}
|
||||
|
||||
docker-build:
|
||||
if: |
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
(
|
||||
github.event.workflow_run.head_branch == 'main' ||
|
||||
startsWith(github.event.workflow_run.head_branch || '', 'v')
|
||||
)
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
@@ -56,19 +61,22 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.workflow_run.head_sha }}
|
||||
fetch-depth: 0
|
||||
- name: Log in to the GitHub Container registry
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
tags: |
|
||||
type=raw,value=latest,enable=${{ github.ref_name == 'main' }}
|
||||
type=raw,value=latest,enable=${{ github.event.workflow_run.head_branch == 'main' }}
|
||||
type=semver,pattern={{raw}}
|
||||
flavor: |
|
||||
latest=auto
|
||||
@@ -77,13 +85,23 @@ jobs:
|
||||
ghcr.io/${{ github.repository }}
|
||||
|
||||
- name: Build and push Docker images
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
docker-manifest:
|
||||
if: |
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
(
|
||||
github.event.workflow_run.head_branch == 'main' ||
|
||||
startsWith(github.event.workflow_run.head_branch || '', 'v')
|
||||
)
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
needs:
|
||||
- docker-build
|
||||
- docker-clean-metadata
|
||||
@@ -95,7 +113,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Log in to the GitHub Container registry
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
36
.pre-commit-config.yaml
Normal file
36
.pre-commit-config.yaml
Normal file
@@ -0,0 +1,36 @@
|
||||
repos:
|
||||
# Fast built-in hooks (Rust-native, no dependencies)
|
||||
- repo: builtin
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
- id: check-yaml
|
||||
- id: check-merge-conflict
|
||||
- id: check-added-large-files
|
||||
args: ['--maxkb=1024']
|
||||
|
||||
# Local hooks that run project-specific tools
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: cargo-fmt-check
|
||||
name: Cargo Format Check
|
||||
entry: cargo fmt --all -- --check
|
||||
language: system
|
||||
files: '\.rs$'
|
||||
pass_filenames: false
|
||||
|
||||
- id: cargo-clippy
|
||||
name: Cargo Clippy
|
||||
entry: cargo clippy -- -D warnings
|
||||
language: system
|
||||
files: '\.rs$'
|
||||
pass_filenames: false
|
||||
priority: 100
|
||||
|
||||
- id: test-unit
|
||||
name: Unit Tests
|
||||
entry: just test
|
||||
language: system
|
||||
files: '\.rs$'
|
||||
pass_filenames: false
|
||||
priority: 100
|
||||
141
CHANGELOG.md
141
CHANGELOG.md
@@ -1,3 +1,144 @@
|
||||
# (2026-05-09) Version 1.19.1
|
||||
|
||||
- (**Internal Improvement**) Update [async-openai](https://crates.io/crates/async-openai) to 0.38.0.
|
||||
|
||||
- (**Internal Improvement**) Dependency updates.
|
||||
|
||||
|
||||
# (2026-05-09) Version 1.19.0
|
||||
|
||||
- (**Internal Improvement**) Update [matrix-sdk](https://crates.io/crates/matrix-sdk) from 0.16 to 0.17 and [mxlink](https://crates.io/crates/mxlink) to 1.14.0. matrix-sdk 0.17 dropped its `native-tls` feature and now uses [rustls](https://github.com/rustls/rustls) exclusively as its TLS backend.
|
||||
|
||||
- (**Internal Improvement**) Bump the pinned Rust toolchain from 1.93.0 to 1.95.0 (in `rust-toolchain.toml` and the Docker build images).
|
||||
|
||||
- (**Internal Improvement**) Dependency updates.
|
||||
|
||||
|
||||
# (2026-04-11) Version 1.18.0
|
||||
|
||||
- (**Bugfix**) Fix the bot not sending a welcome message when joining a room on homeservers (like [Continuwuity](https://continuwuity.org/)) that place the join membership event in the sync response's `state` block rather than the `timeline` block, via [mxlink](https://crates.io/crates/mxlink) 1.13.1
|
||||
|
||||
- (**Improvement**) Update [tiktoken-rs](https://crates.io/crates/tiktoken-rs) to 0.11, adding tokenization support for newer GPT models (gpt-5.x, codex, etc.) and fixing context sizes for o1-mini/chatgpt-4o/gpt-4.5
|
||||
|
||||
- (**Internal Improvement**) Dependency updates
|
||||
|
||||
|
||||
# (2026-03-25) Version 1.17.0
|
||||
|
||||
- (**Feature**) Add `text-generation sender-context-mode` for attaching sender metadata to conversation messages. See the [💬 Text Generation](./docs/configuration/text-generation.md#-sender-context-mode) documentation for details. Thanks to [kschwank](https://github.com/kschwank) for the contribution in [#104](https://github.com/etkecc/baibot/pull/104)!
|
||||
|
||||
|
||||
# (2026-03-24) Version 1.16.1
|
||||
|
||||
- (**Bugfix**) Fix compatibility with [async-openai](https://crates.io/crates/async-openai) 0.34.0 by populating the new `phase` field required for OpenAI Responses API message inputs. baibot does not currently distinguish between assistant `commentary` and `final_answer` turns, so using `None` preserves the previous behavior while remaining compatible with the updated crate.
|
||||
|
||||
- (**Internal Improvement**) Dependency updates.
|
||||
|
||||
|
||||
# (2026-03-20) Version 1.16.0
|
||||
|
||||
- (**Feature**) Add support for file attachments (`m.file` Matrix messages) in conversations. Files like PDFs, text documents, spreadsheets, code files, etc. are now downloaded and forwarded to the LLM alongside the conversation context, similar to how images (`m.image`) are already handled. See the [💬 Text Generation](./docs/features.md#-text-generation) documentation for details and known limitations.
|
||||
|
||||
- (**Improvement**) Use the [mime_guess](https://crates.io/crates/mime_guess) crate for MIME type detection from file extensions, replacing a hand-maintained mapping. This covers hundreds of file extensions out of the box.
|
||||
|
||||
|
||||
|
||||
# (2026-03-07) Version 1.15.0
|
||||
|
||||
- (**Feature**) Add support for authentication via access tokens (for [Matrix Authentication Service](https://github.com/element-hq/matrix-authentication-service)/OIDC-enabled homeservers) as an alternative to password authentication. See [🔐 Authentication](./docs/configuration/authentication.md) for setup details. Thanks to [Taylor Southwick](https://github.com/twsouthwick) for the contribution in [#83](https://github.com/etkecc/baibot/pull/83)!
|
||||
|
||||
- (**Internal Improvement**) Pin the Rust toolchain to `1.93.0` in both CI and local development to avoid `matrix-sdk` build failures on newer stable toolchains.
|
||||
|
||||
- (**Internal Improvement**) Documentation updates.
|
||||
|
||||
- (**Internal Improvement**) Dependency updates.
|
||||
|
||||
|
||||
# (2026-02-18) Version 1.14.3
|
||||
|
||||
- (**Internal Improvement**) Add [Renovate](https://docs.renovatebot.com/) configuration for automated dependency updates
|
||||
|
||||
- (**Internal Improvement**) Dependency updates
|
||||
|
||||
|
||||
# (2026-02-18) Version 1.14.2
|
||||
|
||||
- (**Internal Improvement**) Dependency updates
|
||||
|
||||
- (**Internal Improvement**) Reorganize the development environment to support [Continuwuity](https://continuwuity.org/) as a homeserver choice (in addition to [Synapse](https://github.com/element-hq/synapse)). Continuwuity is now the default for its lighter footprint (no external database required). See [development docs](./docs/development.md) for details.
|
||||
|
||||
|
||||
# (2026-02-10) Version 1.14.1
|
||||
|
||||
- (**Security**) Dependency updates to fix security vulnerabilities ([time](https://crates.io/crates/time) stack exhaustion DoS, [bytes](https://crates.io/crates/bytes) integer overflow), via [mxlink](https://crates.io/crates/mxlink) 1.12.0
|
||||
|
||||
- (**Internal Improvement**) Switch from deprecated [serde_yaml](https://crates.io/crates/serde_yaml) to its maintained fork [serde_yaml_ng](https://crates.io/crates/serde_yaml_ng)
|
||||
|
||||
- (**Internal Improvement**) Add [prek](https://github.com/nicholasgasior/prek) pre-commit hooks via [mise](https://mise.jdx.dev/) for automated code quality checks (formatting, clippy, tests)
|
||||
|
||||
- (**Internal Improvement**) Fix clippy warnings and formatting issues
|
||||
|
||||
|
||||
# (2026-02-04) Version 1.14.0
|
||||
|
||||
- (**Feature**) The `openai` provider now uses OpenAI's [Responses API](https://platform.openai.com/docs/api-reference/responses) (instead of the older Chat Completions API), adding support for [🛠️ built-in tools](./docs/features.md#️-built-in-tools-openai-only) (`web_search` and `code_interpreter`). These tools are **disabled by default** and can be enabled via the `text_generation.tools` configuration (see the [sample configuration](https://github.com/etkecc/baibot/blob/c70387b0c38d8d0f30bba2179a2a21a3710dbeaf/docs/sample-provider-configs/openai.yml#L12-L15)). To enable tools on an existing agent, you need to [update the agent](./docs/agents.md#updating-agents) to re-create it with the `text_generation.tools` section added and enable the tools you need. Thanks to [Layla Manley](https://github.com/yeslayla) for the contribution in [#62](https://github.com/etkecc/baibot/pull/62)!
|
||||
|
||||
- (**Bugfix**) Fix sticker generation for newer GPT image models (`gpt-image-1`, `gpt-image-1-mini`, `gpt-image-1.5`) which don't support the previously hardcoded `256x256` size (minimum is `1024x1024`)
|
||||
|
||||
- (**Internal Improvement**) Dependency updates
|
||||
|
||||
|
||||
# (2026-01-23) Version 1.13.0
|
||||
|
||||
- (**Improvement**) Extend auto-switching to support cheaper models (`gpt-image-1-mini`) for `gpt-image-1` and `gpt-image-1.5` when generating stickers ([e0b4a40](https://github.com/etkecc/baibot/commit/e0b4a40))
|
||||
|
||||
- (**Internal Improvement**) Upgrade Rust compiler (1.92.0 -> 1.93.0) ([691aeeb](https://github.com/etkecc/baibot/commit/691aeeb))
|
||||
|
||||
- (**Internal Improvement**) Dependency updates
|
||||
|
||||
|
||||
# (2025-12-21) Version 1.12.0
|
||||
|
||||
- (**Improvement**) Upgrade [async-openai](https://crates.io/crates/async-openai) (0.31.1 -> 0.32.2) and add support for OpenAI's `gpt-image-1.5` model ([08c689a](https://github.com/etkecc/baibot/commit/08c689a), [f7bf3d7](https://github.com/etkecc/baibot/commit/f7bf3d7))
|
||||
|
||||
- (**Internal Improvement**) Dependency updates
|
||||
|
||||
|
||||
# (2025-12-15) Version 1.11.0
|
||||
|
||||
- (**Feature**) Add support for custom avatars via file path and for keeping the already-set avatar (for those who wish to manage it by themselves via other means). See the [sample config](./etc/app/config.yml.dist) for details. ([062fbbb](https://github.com/etkecc/baibot/commit/062fbbb8ef9ad600db483a431c5c782402191023))
|
||||
|
||||
- (**Internal Improvement**) Dependency updates ([99bde53](https://github.com/etkecc/baibot/commit/99bde53ef648a5a9086a96778fde4a9dbc1ede58))
|
||||
|
||||
- (**Internal Improvement**) Documentation updates ([b3fd8e5](https://github.com/etkecc/baibot/commit/b3fd8e548f83fe46398ced4760d7e2bb7588c24d))
|
||||
|
||||
- (**Internal Improvement**) Upgrade Rust compiler (1.91.1 -> 1.92.0) ([22906aa](https://github.com/etkecc/baibot/commit/22906aa2d3cae51815fad2560a545eaa69c247b6))
|
||||
|
||||
|
||||
# (2025-12-06) Version 1.10.0
|
||||
|
||||
- (**Internal Improvement**) Dependency updates. This version is based on [mxlink](https://crates.io/crates/mxlink)@1.11.0 (which is based on the newly released [matrix-sdk](https://crates.io/crates/matrix-sdk)@[0.16.0](https://github.com/matrix-org/matrix-rust-sdk/releases/tag/matrix-sdk-0.16.0).
|
||||
|
||||
# (2025-11-30) Version 1.9.0
|
||||
|
||||
- (**Internal Improvement**) Upgrade [async-openai](https://crates.io/crates/async-openai) from our own etkecc fork (0.28.1-patched) to the official upstream version 0.31.1. This upgrade required some code adaptations to the new module structure, etc. While tested, regressions are possible.
|
||||
|
||||
# (2025-11-28) Version 1.8.3
|
||||
|
||||
- (**Improvement**) Add support for the `BAIBOT_PERSISTENCE_SESSION_ENCRYPTION_KEY` environment variable for configuring `persistence.session_encryption_key`
|
||||
|
||||
- (**Improvement**) Add support for the `BAIBOT_USER_ENCRYPTION_RECOVERY_RESET_ALLOWED` environment variable for configuring `user.encryption.recovery_reset_allowed`
|
||||
|
||||
- (**Internal Improvement**) Dependency updates.
|
||||
|
||||
# (2025-11-20) Version 1.8.2
|
||||
|
||||
- (**Internal Improvement**) Dependency and compiler updates (Rust 1.89.0 -> 1.91.1).
|
||||
|
||||
# (2025-09-12) Version 1.8.1
|
||||
|
||||
- (**Internal Improvement**) Dependency updates.
|
||||
|
||||
# (2025-09-08) Version 1.8.0
|
||||
|
||||
- (**Internal Improvement**) Upgrade [mxlink](https://crates.io/crates/mxlink) (1.9.0 -> 1.10.0) and [matrix-sdk](https://crates.io/crates/matrix-sdk) (0.13.0 -> 0.14.0)
|
||||
|
||||
2833
Cargo.lock
generated
2833
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
20
Cargo.toml
20
Cargo.toml
@@ -7,7 +7,7 @@ license = "AGPL-3.0-or-later"
|
||||
readme = "README.md"
|
||||
keywords = ["matrix", "chat", "bot", "AI", "LLM"]
|
||||
include = ["/etc/assets/baibot-torso-768.png", "/src", "/README.md", "/CHANGELOG.md", "/LICENSE"]
|
||||
version = "1.8.0"
|
||||
version = "1.19.1"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
@@ -17,23 +17,23 @@ path = "src/lib.rs"
|
||||
[dependencies]
|
||||
anthropic = { git = "https://github.com/etkecc/anthropic-rs.git", branch = "fix-content-block-image" }
|
||||
anyhow = "1.0.*"
|
||||
async-openai = { git = "https://github.com/etkecc/async-openai", branch = "async-openai-v0.28.1-patched" }
|
||||
async-openai = { version = "0.38.0", features = ["audio", "chat-completion", "image", "responses"] }
|
||||
base64 = "0.22.*"
|
||||
chrono = { version = "0.4.*", default-features = false, features = ["std", "now"] }
|
||||
# We'd rather not depend on this, but we cannot use the ruma-events EventContent macro without it.
|
||||
# We add the `native-tls` feature, because of https://github.com/etkecc/rust-mxlink/issues/1
|
||||
matrix-sdk = { version = "0.14.0", default-features = false, features = ["native-tls"] }
|
||||
matrix-sdk = { version = "0.17.0", default-features = false }
|
||||
mime_guess = "2.0.*"
|
||||
mxidwc = "1.0.*"
|
||||
mxlink = ">=1.10.0"
|
||||
mxlink = ">=1.14.0"
|
||||
etke_openai_api_rust = "0.1.*"
|
||||
quick_cache = "0.6.*"
|
||||
regex = "1.11.*"
|
||||
regex = "1.12.*"
|
||||
serde = { version = "1.0.*", features = ["derive"], default-features = false }
|
||||
serde_json = "1.0.*"
|
||||
serde_yaml = "0.9.*"
|
||||
tempfile = "3.21.*"
|
||||
tiktoken-rs = { version = "0.7.*", default-features = false }
|
||||
tokio = { version = "1.47.*", features = ["rt", "rt-multi-thread", "macros"] }
|
||||
serde_yaml_ng = "0.10.*"
|
||||
tempfile = "3.27.*"
|
||||
tiktoken-rs = { version = "0.11.*", default-features = false }
|
||||
tokio = { version = "1.52.*", features = ["rt", "rt-multi-thread", "macros"] }
|
||||
tracing = "0.1.*"
|
||||
tracing-subscriber = { version = "0.3.*", features = ["env-filter"] }
|
||||
url = "2.5.*"
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# #
|
||||
#######################################
|
||||
|
||||
FROM docker.io/rust:1.89.0-slim-trixie AS build
|
||||
FROM docker.io/rust:1.95.0-slim-trixie AS build
|
||||
|
||||
RUN apt-get update && apt-get install -y build-essential pkg-config libssl-dev libsqlite3-dev
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# #
|
||||
#######################################
|
||||
|
||||
FROM docker.io/rust:1.89.0-slim-trixie AS build
|
||||
FROM docker.io/rust:1.95.0-slim-trixie AS build
|
||||
|
||||
RUN apt-get update && apt-get install -y build-essential pkg-config libssl-dev libsqlite3-dev
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ It's influenced by [chaz](https://github.com/arcuru/chaz), but does **not** use
|
||||
|
||||
- Supports **different use purposes** (depending on the [☁️ provider](./docs/providers.md) & model):
|
||||
|
||||
- [💬 text-generation](./docs/features.md#-text-generation): communicating with you via text (though certain models may "see" images as well)
|
||||
- [💬 text-generation](./docs/features.md#-text-generation): communicating with you via text (though certain models may "see" images as well). The [OpenAI provider](./docs/providers.md#openai) also supports [🛠️ built-in tools](./docs/features.md#️-built-in-tools-openai-only) (web search, code interpreter)
|
||||
- [🦻 speech-to-text](./docs/features.md#-speech-to-text): turning your voice messages into text
|
||||
- [🗣️ text-to-speech](./docs/features.md#%EF%B8%8F-text-to-speech): turning bot or users text messages into voice messages
|
||||
- [🖌️ image-generation](./docs/features.md#image-generation): creating and editing images based on instructions
|
||||
|
||||
@@ -43,7 +43,8 @@ Administrators cannot be changed without adjusting the bot's configuration on th
|
||||
|
||||
Room-local agent managers are users privileged to **create their own [agents](./agents.md)** (see `!bai agent`) in rooms.
|
||||
|
||||
**⚠️ WARNING**: Letting regular users create agents which contact arbitrary network services **may be a security issue**.
|
||||
> [!WARNING]
|
||||
> Letting regular users create agents which contact arbitrary network services **may be a security issue**.
|
||||
|
||||
The following commands are available:
|
||||
- **Show** the currently allowed users: `!bai access room-local-agent-managers`
|
||||
|
||||
@@ -12,12 +12,17 @@ This file is created from the template found in [etc/app/config.yml.dist](../../
|
||||
|
||||
Certain keys can be left unset, in which case [📝 hardcoded defaults](../../src/entity/cfg/defaults.rs) would be used.
|
||||
|
||||
Each configuration key found in the YAML configuration can be overridden by setting an environment variable (dots should be replaced with `_`). Example:
|
||||
Some configuration keys found in the YAML configuration can be overridden by setting an environment variable (dots should be replaced with `_`). Example:
|
||||
|
||||
- to override `command_prefix`, set an environment variable `BAIBOT_COMMAND_PREFIX`
|
||||
- to override `homeserver.server_name`, set an environment variable `BAIBOT_HOMESERVER_SERVER_NAME`
|
||||
|
||||
The static configuration contains an `initial_global_config` key, which is used to populate the bot's global configuration (stored as [dynamic configuration](#dynamic-configuration)) the first time the bot starts. Modifying this subsequently will not have any effect. After initial global configuration creation, it's expected to be managed dynamically via chat commands.
|
||||
You can see the list of supported environment variables in the [🦀 src/entity/cfg/env.rs](../../src/entity/cfg/env.rs) file.
|
||||
|
||||
> [!WARNING]
|
||||
> The static configuration contains an `initial_global_config` key, which is used to populate the bot's global configuration (stored as [dynamic configuration](#dynamic-configuration)) the first time the bot starts. Modifying this subsequently will not have any effect. After initial global configuration creation, it's expected to be managed dynamically via chat commands.
|
||||
|
||||
For Matrix-account authentication setup, see [🔐 Authentication](./authentication.md).
|
||||
|
||||
|
||||
### Dynamic configuration
|
||||
|
||||
23
docs/configuration/authentication.md
Normal file
23
docs/configuration/authentication.md
Normal file
@@ -0,0 +1,23 @@
|
||||
## 🔐 Authentication
|
||||
|
||||
baibot supports 2 authentication modes for the Matrix account (`user.*` keys in config).
|
||||
|
||||
Set **exactly one** mode. If both are set (or neither is set), startup validation fails.
|
||||
|
||||
### Password authentication
|
||||
|
||||
- Config key: `user.password`
|
||||
- Environment variable: `BAIBOT_USER_PASSWORD`
|
||||
|
||||
### Access token authentication
|
||||
|
||||
- Config keys: `user.access_token` + `user.device_id`
|
||||
- Environment variables: `BAIBOT_USER_ACCESS_TOKEN` + `BAIBOT_USER_DEVICE_ID`
|
||||
|
||||
Access-token authentication is useful for OIDC-enabled homeservers (e.g. those using [Matrix Authentication Service](https://github.com/element-hq/matrix-authentication-service)).
|
||||
|
||||
Example token-generation command:
|
||||
|
||||
```sh
|
||||
mas-cli manage issue-compatibility-token <username> [device_id]
|
||||
```
|
||||
@@ -8,7 +8,7 @@ You can also use **different models within the same room** (e.g. [💬 text-gene
|
||||
|
||||
The bot supports the following use-purposes:
|
||||
|
||||
- [💬 text-generation](../features.md#-text-generation): communicating with you via text (though certain models may "see" images as well)
|
||||
- [💬 text-generation](../features.md#-text-generation): communicating with you via text (though certain models may also process images and files)
|
||||
- [🦻 speech-to-text](../features.md#-speech-to-text): turning your voice messages into text
|
||||
- [🗣️ text-to-speech](../features.md#️-text-to-speech): turning bot or users text messages into voice messages
|
||||
- [🖌️ image-generation](../features.md#image-generation): generating images based on instructions
|
||||
|
||||
@@ -57,6 +57,25 @@ This feature relies on [tokenization](https://en.wikipedia.org/wiki/Large_langua
|
||||
This setting is **disabled by default**, but can be enabled via `!bai config room text-generation set-context-management-enabled true` (this can also be set globally, see [🛠️ Room Settings](./README.md#room-settings)).
|
||||
|
||||
|
||||
### 👤 Sender Context Mode
|
||||
|
||||
In multi-user rooms, it may be useful for the model to know which participant sent each message in the conversation context.
|
||||
|
||||
To support this, the bot has a `text-generation sender-context-mode` setting, which can be set to:
|
||||
|
||||
- (default) `disabled`: do not attach sender metadata to messages before sending them to the model
|
||||
|
||||
- `matrix_user_id`: prefix text messages with the sender's Matrix user ID, for example: `[sender=@alice:example.com] Hello bot`
|
||||
|
||||
- `matrix_user_id_and_timestamp`: prefix text messages with the sender's Matrix user ID and the message timestamp, for example: `[sender=@alice:example.com sent_at=2026-03-23T14:30:00Z] Hello bot`
|
||||
|
||||
This sender metadata is attached to conversation messages before they are sent to the model provider. It applies to user and assistant text messages, but not to system prompts or non-text content.
|
||||
|
||||
⚠️ Enabling this sends Matrix user IDs, and optionally timestamps, to the model provider.
|
||||
|
||||
Example: `!bai config room text-generation set-sender-context-mode matrix_user_id` (this can also be set globally, see [🛠️ Room Settings](./README.md#room-settings))
|
||||
|
||||
|
||||
### ⌨️ Prompt Override
|
||||
|
||||
You can override the [system prompt](https://huggingface.co/docs/transformers/en/tasks/prompting) configured at the [🤖 agent](../agents.md) level.
|
||||
|
||||
@@ -18,6 +18,27 @@ For local development, we run all dependency services in [🐋 Docker](https://w
|
||||
- (Optional) an API key for some Large Language Model [☁️ provider](./providers.md) (e.g. [OpenAI](./providers.md#openai)), though we recommend using [LocalAI](#localai) or [Ollama](#ollama) for local development
|
||||
|
||||
|
||||
### Choosing a homeserver
|
||||
|
||||
The development environment supports two homeserver implementations:
|
||||
|
||||
- **[Continuwuity](https://continuwuity.org/)** (default) — lightweight, no external database required. Good for most development needs.
|
||||
- **[Synapse](https://github.com/element-hq/synapse)** — the reference implementation, bundled with Postgres. Use this if you need Synapse-specific behavior.
|
||||
|
||||
To choose a homeserver (optional — defaults to Continuwuity if skipped):
|
||||
|
||||
```sh
|
||||
just homeserver-init continuwuity # or: just homeserver-init synapse
|
||||
```
|
||||
|
||||
The choice is stored in `var/homeserver` and affects all subsequent commands.
|
||||
|
||||
> **Note:** If you switch homeservers after initial setup, you will need to:
|
||||
> - Delete `var/app/local/` and/or `var/app/container/` (app config and data)
|
||||
> - Delete `var/services/element-web/` (to regenerate its config)
|
||||
> - Re-run the prepare and user registration steps
|
||||
|
||||
|
||||
### Getting started guide
|
||||
|
||||
Developing [locally](#running-locally) is possible, but requires a [Rust](https://www.rust-lang.org/) toolchain.
|
||||
@@ -28,11 +49,12 @@ In any case, you will need [🐋 Docker](https://www.docker.com/) as [dependency
|
||||
|
||||
#### Running locally
|
||||
|
||||
1. Start the core dependency services (Postgres, Synapse, Element Web): `just services-start`
|
||||
2. (Only the first time around) Prepare initial app configuration in `var/app/local/config.yml`: `just app-local-prepare`
|
||||
3. (Only the first time around) [Prepare your configuration file](#prepare-your-configuration-file)
|
||||
4. (Only the first time around) Prepare initial default Matrix user accounts (`admin` and `baibot`): `just users-prepare`
|
||||
5. (Optional) Start additional services depending on which [agent provider you've chosen](#choosing-an-agent-provider):
|
||||
1. (Optional) Choose a homeserver: `just homeserver-init continuwuity` (or `synapse`). Default is `continuwuity`.
|
||||
2. Start the homeserver and Element Web: `just services-start`
|
||||
3. (Only the first time around) Prepare initial app configuration in `var/app/local/config.yml`: `just app-local-prepare`
|
||||
4. (Only the first time around) [Prepare your configuration file](#prepare-your-configuration-file)
|
||||
5. (Only the first time around) Prepare initial default Matrix user accounts (`admin` and `baibot`): `just users-prepare`
|
||||
6. (Optional) Start additional services depending on which [agent provider you've chosen](#choosing-an-agent-provider):
|
||||
- for [LocalAI](#localai):
|
||||
- Start services: `just localai-start`
|
||||
- Wait a while for LocalAI to start up. It has a lot of models to download. Monitor progress using `just localai-tail-logs`
|
||||
@@ -40,12 +62,12 @@ In any case, you will need [🐋 Docker](https://www.docker.com/) as [dependency
|
||||
- for [Ollama](#ollama):
|
||||
- Start services: `just ollama-start`
|
||||
- (Only the first time around) Pull the model configured in `agents.static_definitions` in the configuration file: `just ollama-pull-model gemma2:2b`
|
||||
6. Start the bot: `just run-locally`
|
||||
7. Go to http://element.127.0.0.1.nip.io:42025/ and login with `admin` / `admin`
|
||||
8. Create a new room and invite `@baibot:synapse.127.0.0.1.nip.io`
|
||||
9. When done, stop the bot (`Ctrl` + `C`)
|
||||
10. Stop the core dependency services: `just services-stop`
|
||||
11. (Optional) Stop additional services:
|
||||
7. Start the bot: `just run-locally`
|
||||
8. Go to http://element.127.0.0.1.nip.io:42025/ and login with `admin` / `admin`
|
||||
9. Create a new room and invite `@baibot:continuwuity.127.0.0.1.nip.io` (or `@baibot:synapse.127.0.0.1.nip.io` if using Synapse)
|
||||
10. When done, stop the bot (`Ctrl` + `C`)
|
||||
11. Stop the services: `just services-stop`
|
||||
12. (Optional) Stop additional services:
|
||||
- for [LocalAI](#localai): `just localai-stop`
|
||||
- for [Ollama](#ollama): `just ollama-stop`
|
||||
|
||||
@@ -54,11 +76,12 @@ In any case, you will need [🐋 Docker](https://www.docker.com/) as [dependency
|
||||
|
||||
You can avoid having a [Rust](https://www.rust-lang.org/) toolchain installed locally and build/run this in a container.
|
||||
|
||||
1. Start the core dependency services (Postgres, Synapse, Element Web): `just services-start`
|
||||
2. (Only the first time around) Prepare initial app configuration in `var/app/container/config.yml`: `just app-container-prepare`
|
||||
3. (Only the first time around) [Prepare your configuration file](#prepare-your-configuration-file)
|
||||
4. (Only the first time around) Prepare initial default Matrix user accounts (`admin` and `baibot`): `just users-prepare`
|
||||
5. (Optional) Start additional services depending on which [agent provider you've chosen](#choosing-an-agent-provider):
|
||||
1. (Optional) Choose a homeserver: `just homeserver-init continuwuity` (or `synapse`). Default is `continuwuity`.
|
||||
2. Start the homeserver and Element Web: `just services-start`
|
||||
3. (Only the first time around) Prepare initial app configuration in `var/app/container/config.yml`: `just app-container-prepare`
|
||||
4. (Only the first time around) [Prepare your configuration file](#prepare-your-configuration-file)
|
||||
5. (Only the first time around) Prepare initial default Matrix user accounts (`admin` and `baibot`): `just users-prepare`
|
||||
6. (Optional) Start additional services depending on which [agent provider you've chosen](#choosing-an-agent-provider):
|
||||
- for [LocalAI](#localai):
|
||||
- Start services: `just localai-start`
|
||||
- Wait a while for LocalAI to start up. It has a lot of models to download. Monitor progress using `just localai-tail-logs`
|
||||
@@ -66,12 +89,12 @@ You can avoid having a [Rust](https://www.rust-lang.org/) toolchain installed lo
|
||||
- for [Ollama](#ollama):
|
||||
- Start services: `just ollama-start`
|
||||
- (Only the first time around) Pull the model configured in `agents.static_definitions` in the configuration file: `just ollama-pull-model gemma2:2b`
|
||||
6. Start the bot: `just run-in-container`
|
||||
7. Go to http://element.127.0.0.1.nip.io:42025/ and login with `admin` / `admin`
|
||||
8. Create a new room and invite `@baibot:synapse.127.0.0.1.nip.io`
|
||||
9. When done, stop the bot (`Ctrl` + `C`)
|
||||
10. Stop the dependency services: `just services-stop`
|
||||
11. (Optional) Stop additional services:
|
||||
7. Start the bot: `just run-in-container`
|
||||
8. Go to http://element.127.0.0.1.nip.io:42025/ and login with `admin` / `admin`
|
||||
9. Create a new room and invite `@baibot:continuwuity.127.0.0.1.nip.io` (or `@baibot:synapse.127.0.0.1.nip.io` if using Synapse)
|
||||
10. When done, stop the bot (`Ctrl` + `C`)
|
||||
11. Stop the services: `just services-stop`
|
||||
12. (Optional) Stop additional services:
|
||||
- for [LocalAI](#localai): `just localai-stop`
|
||||
- for [Ollama](#ollama): `just ollama-stop`
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ You can also use **different models within the same room** (e.g. [💬 text-gene
|
||||
|
||||
The bot supports the following use-purposes:
|
||||
|
||||
- [💬 text-generation](#-text-generation): communicating with you via text (though certain models may "see" images as well)
|
||||
- [💬 text-generation](#-text-generation): communicating with you via text (though certain models may also process images and files)
|
||||
- [🦻 speech-to-text](#-speech-to-text): turning your voice messages into text
|
||||
- [🗣️ text-to-speech](#%EF%B8%8F-text-to-speech): turning bot or users text messages into voice messages
|
||||
- [🖌️ image-generation](#%EF%B8%8F-image-generation): generating images based on instructions
|
||||
@@ -26,12 +26,14 @@ Text Generation is the bot's ability to **respond to users' messages with text**
|
||||
|
||||

|
||||
|
||||
Some models also support vision, so you may be able to mix text and images in the same conversation.
|
||||
Some models also support vision and document understanding, so you may be able to mix text, images, and files (PDFs, text documents, etc.) in the same conversation. Note that certain providers may not support all file types or may have issues with specific files (e.g. scanned/image-based PDFs). If a file is rejected by the provider, the conversation thread may become unusable — start a new thread to work around this.
|
||||
|
||||
In multi-user (group) rooms, to avoid disturbing the normal conversation between people, the bot is auto-configured to only respond to messages starting with the command prefix (`!bai`) or direct mentions via the [💬 Text Generation / 🗟 Prefix Requirement Type](./configuration/text-generation.md#-prefix-requirement-type) setting.
|
||||
|
||||
Normally, the bot only responds to allowed [👥 Users](./access.md#-users). In certain cases, it's useful for an allowed user to provoke the bot to respond even in foreign threads or reply chains. You can learn more about this feature in the [On-demand involvement](./features.md#on-demand-involvement) section below.
|
||||
|
||||
If needed, the bot can also attach sender metadata to conversation messages before sending them to the model, which can help the model distinguish between participants in multi-user rooms. See [🛠️ Configuration / 💬 Text Generation / 👤 Sender Context Mode](./configuration/text-generation.md#-sender-context-mode).
|
||||
|
||||
A few other features (like [🗣️ Text-to-Speech](#️-text-to-speech) and [🦻 Speech-to-Text](#-speech-to-text)) combine well with Text Generation, so you **don't necessarily need to communicate with the bot via text** (with [Seamless voice interaction](#seamless-voice-interaction), you can communicate only with voice).
|
||||
|
||||
You may also wish to see:
|
||||
@@ -40,6 +42,23 @@ You may also wish to see:
|
||||
- [📖 Usage / 💬 Text Generation](./usage.md#-text-generation) section for more details on how to use the bot for Text Generation in a room
|
||||
|
||||
|
||||
#### 🛠️ Built-in Tools (OpenAI only)
|
||||
|
||||
|
||||
|
||||
The [OpenAI provider](./providers.md#openai) supports built-in tools that extend the model's capabilities:
|
||||
|
||||
- [🔍 Web Search](https://platform.openai.com/docs/guides/tools-web-search) (`web_search`): allows the model to search the web for up-to-date information. [🖼️ Screenshot](./screenshots/text-generation-tools-web-search.webp)
|
||||
|
||||
- [💻 Code Interpreter](https://platform.openai.com/docs/guides/tools-code-interpreter) (`code_interpreter`): allows the model to write and execute Python code in a sandbox
|
||||
|
||||
These tools are **disabled by default** and need to be explicitly enabled in the agent's `text_generation.tools` configuration. See the [OpenAI sample configuration](https://github.com/etkecc/baibot/blob/c70387b0c38d8d0f30bba2179a2a21a3710dbeaf/docs/sample-provider-configs/openai.yml#L12-L15) for reference.
|
||||
|
||||
To enable tools on an existing dynamically-created agent, you need to [update the agent](./agents.md#updating-agents) to re-create it with the `text_generation.tools` section added and enable the tools you need
|
||||
|
||||
💡 **Note**: These tools run on OpenAI's infrastructure and may incur additional costs. Web search results include citations that are incorporated into the response.
|
||||
|
||||
|
||||
#### On-demand involvement
|
||||
|
||||
In the following 2 cases, it's useful to involve the bot in conversations on-demand:
|
||||
|
||||
@@ -23,7 +23,7 @@ The list of supported providers is below.
|
||||
|
||||
### How to choose a provider
|
||||
|
||||
If you're not sure which provider to start with, **we recommend [OpenAI](#openai)** as it's the most popular and has the **widest range of capabilities**: [💬 text-generation](./features.md#-text-generation) (no vision), [🖌️ image-generation](./features.md#️image-generation), [🦻 speech-to-text](./features.md#-speech-to-text), [🗣️ text-to-speech](./features.md#️-text-to-speech).
|
||||
If you're not sure which provider to start with, **we recommend [OpenAI](#openai)** as it's the most popular and has the **widest range of capabilities**: [💬 text-generation](./features.md#-text-generation) (incl. vision, incl. [🛠️ tools](./features.md#️-built-in-tools-openai-only)), [🖌️ image-generation](./features.md#️image-generation), [🦻 speech-to-text](./features.md#-speech-to-text), [🗣️ text-to-speech](./features.md#️-text-to-speech).
|
||||
|
||||
You don't need to choose just one though. The bot supports [mixing & matching models](./features.md#-mixing--matching-models), so you can use multiple providers at the same time.
|
||||
|
||||
@@ -47,7 +47,7 @@ You don't need to choose just one though. The bot supports [mixing & matching mo
|
||||
|
||||
- 🆔 Identifier: `anthropic`
|
||||
- 🔗 Links: [🏠 Home page](https://www.anthropic.com/), [🌐 Wiki](https://en.wikipedia.org/wiki/Anthropic), [👤 Sign up](https://console.anthropic.com/), [📋 Models list](https://docs.anthropic.com/en/docs/about-claude/models)
|
||||
- 🌟 Capabilities: [💬 text-generation](./features.md#-text-generation) (incl. vision)
|
||||
- 🌟 Capabilities: [💬 text-generation](./features.md#-text-generation) (incl. vision, no tools)
|
||||
- 🗲 Quick start:
|
||||
- create a room-local agent: `!bai agent create-room-local anthropic my-anthropic-agent`
|
||||
- create a global agent: `!bai agent create-global anthropic my-anthropic-agent`
|
||||
@@ -61,7 +61,7 @@ You don't need to choose just one though. The bot supports [mixing & matching mo
|
||||
|
||||
- 🆔 Identifier: `groq`
|
||||
- 🔗 Links: [🏠 Home page](https://groq.com/), [🌐 Wiki](https://en.wikipedia.org/wiki/Groq), [👤 Sign up](https://console.groq.com/login), [📋 Models list](https://console.groq.com/docs/models)
|
||||
- 🌟 Capabilities: [💬 text-generation](./features.md#-text-generation) (no vision), [🦻 speech-to-text](./features.md#-speech-to-text)
|
||||
- 🌟 Capabilities: [💬 text-generation](./features.md#-text-generation) (no vision, no tools), [🦻 speech-to-text](./features.md#-speech-to-text)
|
||||
- 🗲 Quick start:
|
||||
- create a room-local agent: `!bai agent create-room-local groq my-groq-agent`
|
||||
- create a global agent: `!bai agent create-global groq my-groq-agent`
|
||||
@@ -75,7 +75,7 @@ You don't need to choose just one though. The bot supports [mixing & matching mo
|
||||
|
||||
- 🆔 Identifier: `localai`
|
||||
- 🔗 Links: [🏠 Home page](https://localai.io/), [📋 Models list](https://localai.io/gallery.html)
|
||||
- 🌟 Capabilities: [💬 text-generation](./features.md#-text-generation) (no vision), [🗣️ text-to-speech](./features.md#️-text-to-speech), [🦻 speech-to-text](./features.md#-speech-to-text)
|
||||
- 🌟 Capabilities: [💬 text-generation](./features.md#-text-generation) (no vision, no tools), [🗣️ text-to-speech](./features.md#️-text-to-speech), [🦻 speech-to-text](./features.md#-speech-to-text)
|
||||
- 🗲 Quick start:
|
||||
- create a room-local agent: `!bai agent create-room-local localai my-localai-agent`
|
||||
- create a global agent: `!bai agent create-global localai my-localai-agent`
|
||||
@@ -89,7 +89,7 @@ You don't need to choose just one though. The bot supports [mixing & matching mo
|
||||
|
||||
- 🆔 Identifier: `mistral`
|
||||
- 🔗 Links: [🏠 Home page](https://mistral.ai/), [🌐 Wiki](https://en.wikipedia.org/wiki/Mistral_AI), [👤 Sign up](https://auth.mistral.ai/ui/registration), [📋 Models list](https://docs.mistral.ai/getting-started/models/)
|
||||
- 🌟 Capabilities: [💬 text-generation](./features.md#-text-generation) (no vision)
|
||||
- 🌟 Capabilities: [💬 text-generation](./features.md#-text-generation) (no vision, no tools)
|
||||
- 🗲 Quick start:
|
||||
- create a room-local agent: `!bai agent create-room-local mistral my-mistral-agent`
|
||||
- create a global agent: `!bai agent create-global mistral my-mistral-agent`
|
||||
@@ -103,7 +103,7 @@ You don't need to choose just one though. The bot supports [mixing & matching mo
|
||||
|
||||
- 🆔 Identifier: `ollama`
|
||||
- 🔗 Links: [🏠 Home page](https://ollama.com/), [📋 Models list](https://ollama.com/library)
|
||||
- 🌟 Capabilities: [💬 text-generation](./features.md#-text-generation) (no vision)
|
||||
- 🌟 Capabilities: [💬 text-generation](./features.md#-text-generation) (no vision, no tools)
|
||||
- 🗲 Quick start:
|
||||
- create a room-local agent: `!bai agent create-room-local ollama my-ollama-agent`
|
||||
- create a global agent: `!bai agent create-global ollama my-ollama-agent`
|
||||
@@ -120,15 +120,12 @@ For services which are not fully compatible with the OpenAI API, consider using
|
||||
|
||||
- 🆔 Identifier: `openai`
|
||||
- 🔗 Links: [🏠 Home page](https://openai.com/), [🌐 Wiki](https://en.wikipedia.org/wiki/OpenAI), [👤 Sign up](https://platform.openai.com/signup), [📋 Models list](https://platform.openai.com/docs/models)
|
||||
- 🌟 Capabilities: [🖌️ image-generation](./features.md#️-image-creation), [💬 text-generation](./features.md#-text-generation) (incl. vision), [🗣️ text-to-speech](./features.md#️-text-to-speech), [🦻 speech-to-text](./features.md#-speech-to-text)
|
||||
- 🌟 Capabilities: [🖌️ image-generation](./features.md#️-image-creation), [💬 text-generation](./features.md#-text-generation) (incl. vision, incl. [🛠️ tools](./features.md#️-built-in-tools-openai-only)), [🗣️ text-to-speech](./features.md#️-text-to-speech), [🦻 speech-to-text](./features.md#-speech-to-text)
|
||||
- 🗲 Quick start:
|
||||
- create a room-local agent: `!bai agent create-room-local openai my-openai-agent`
|
||||
- create a global agent: `!bai agent create-global openai my-openai-agent`
|
||||
|
||||
💡 When creating an agent, the bot will show you an up-to-date sample configuration for this provider which:
|
||||
|
||||
- in the general case looks [like this](./sample-provider-configs/openai.yml)
|
||||
- for the [o1](https://platform.openai.com/docs/models/o1) models needs to look [like this](./sample-provider-configs/openai-o1.yml)
|
||||
💡 When creating an agent, the bot will show you an up-to-date sample configuration for this provider which looks [like this](./sample-provider-configs/openai.yml).
|
||||
|
||||
|
||||
### OpenAI Compatible
|
||||
@@ -140,7 +137,7 @@ Some of these popular services already have **shortcut** providers (leading to t
|
||||
This provider is just as featureful as the [OpenAI](#openai) provider, but is more compatible with services which do not fully adhere to the [OpenAI API spec](https://github.com/openai/openai-openapi/).
|
||||
|
||||
- 🆔 Identifier: `openai-compatible`
|
||||
- 🌟 Capabilities: [🖌️ image-generation](./features.md#️-image-creation), [💬 text-generation](./features.md#-text-generation) (no vision), [🗣️ text-to-speech](./features.md#️-text-to-speech), [🦻 speech-to-text](./features.md#-speech-to-text)
|
||||
- 🌟 Capabilities: [🖌️ image-generation](./features.md#️-image-creation), [💬 text-generation](./features.md#-text-generation) (no vision, no tools), [🗣️ text-to-speech](./features.md#️-text-to-speech), [🦻 speech-to-text](./features.md#-speech-to-text)
|
||||
- 🗲 Quick start:
|
||||
- create a room-local agent: `!bai agent create-room-local openai-compatible my-openai-compatible-agent`
|
||||
- create a global agent: `!bai agent create-global openai-compatible my-openai-compatible-agent`
|
||||
@@ -154,7 +151,7 @@ This provider is just as featureful as the [OpenAI](#openai) provider, but is mo
|
||||
|
||||
- 🆔 Identifier: `openrouter`
|
||||
- 🔗 Links: [🏠 Home page](https://openrouter.ai/), [👤 Sign up](https://openrouter.ai/), [📋 Models list](https://openrouter.ai/models)
|
||||
- 🌟 Capabilities: [💬 text-generation](./features.md#-text-generation) (no vision)
|
||||
- 🌟 Capabilities: [💬 text-generation](./features.md#-text-generation) (no vision, no tools)
|
||||
- 🗲 Quick start:
|
||||
- create a room-local agent: `!bai agent create-room-local openrouter my-openrouter-agent`
|
||||
- create a global agent: `!bai agent create-global openrouter my-openrouter-agent`
|
||||
@@ -168,7 +165,7 @@ This provider is just as featureful as the [OpenAI](#openai) provider, but is mo
|
||||
|
||||
- 🆔 Identifier: `together-ai`
|
||||
- 🔗 Links: [🏠 Home page](https://www.together.ai/), [👤 Sign up](https://api.together.ai/signup), [📋 Models list](https://api.together.xyz/models)
|
||||
- 🌟 Capabilities: [💬 text-generation](./features.md#-text-generation) (no vision)
|
||||
- 🌟 Capabilities: [💬 text-generation](./features.md#-text-generation) (no vision, no tools)
|
||||
- 🗲 Quick start:
|
||||
- create a room-local agent: `!bai agent create-room-local together-ai my-together-ai-agent`
|
||||
- create a global agent: `!bai agent create-global together-ai my-together-ai-agent`
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
base_url: https://api.openai.com/v1
|
||||
api_key: YOUR_API_KEY_HERE
|
||||
text_generation:
|
||||
model_id: o1-mini
|
||||
# o1 models do not support a system prompt
|
||||
prompt: null
|
||||
temperature: 1.0
|
||||
# o1 models do not support max_response_tokens.
|
||||
# They use `max_completion_tokens` as an alternative
|
||||
max_response_tokens: null
|
||||
max_completion_tokens: 16384
|
||||
max_context_tokens: 128000
|
||||
speech_to_text:
|
||||
model_id: whisper-1
|
||||
text_to_speech:
|
||||
model_id: tts-1-hd
|
||||
voice: onyx
|
||||
speed: 1.0
|
||||
response_format: opus
|
||||
image_generation:
|
||||
model_id: gpt-image-1
|
||||
style: null
|
||||
size: null
|
||||
quality: null
|
||||
@@ -1,11 +1,18 @@
|
||||
base_url: https://api.openai.com/v1
|
||||
api_key: YOUR_API_KEY_HERE
|
||||
text_generation:
|
||||
model_id: gpt-5
|
||||
model_id: gpt-5.4
|
||||
prompt: "You are a brief, but helpful bot called {{ baibot_name }} powered by the {{ baibot_model_id }} model. The date/time of this conversation's start is: {{ baibot_conversation_start_time_utc }}."
|
||||
temperature: 1.0
|
||||
max_response_tokens: 16384
|
||||
max_context_tokens: 128000
|
||||
# Reasoning models need to use `max_completion_tokens` instead of `max_response_tokens`.
|
||||
# If you're dealing with a non-reasoning model, specify `max_response_tokens` and unset `max_completion_tokens`.
|
||||
max_response_tokens: null
|
||||
max_completion_tokens: 128000
|
||||
max_context_tokens: 400000
|
||||
# Built-in tools
|
||||
tools:
|
||||
web_search: false
|
||||
code_interpreter: false
|
||||
speech_to_text:
|
||||
model_id: whisper-1
|
||||
text_to_speech:
|
||||
@@ -14,7 +21,7 @@ text_to_speech:
|
||||
speed: 1.0
|
||||
response_format: opus
|
||||
image_generation:
|
||||
model_id: gpt-image-1
|
||||
model_id: gpt-image-1.5
|
||||
style: null
|
||||
size: null
|
||||
quality: null
|
||||
|
||||
BIN
docs/screenshots/text-generation-tools-web-search.webp
Normal file
BIN
docs/screenshots/text-generation-tools-web-search.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
@@ -11,7 +11,7 @@ This is related to the [💬 Text Generation](./features.md#-text-generation) fe
|
||||
|
||||
If there's a text-generation handler agent configured, the bot **may** respond to messages sent in the room.
|
||||
|
||||
Some models also support vision, so you may be able to mix text and images in the same conversation.
|
||||
Some models also support vision and document understanding, so you may be able to mix text, images, and files (PDFs, text documents, etc.) in the same conversation.
|
||||
|
||||
See screenshots of:
|
||||
|
||||
|
||||
@@ -1,16 +1,31 @@
|
||||
homeserver:
|
||||
# The canonical homeserver domain name
|
||||
server_name: synapse.127.0.0.1.nip.io
|
||||
url: http://synapse.127.0.0.1.nip.io:42020
|
||||
server_name: __HOMESERVER_SERVER_NAME__
|
||||
url: __HOMESERVER_URL__
|
||||
|
||||
user:
|
||||
mxid_localpart: baibot
|
||||
|
||||
# Authentication: set EITHER password OR access_token + device_id.
|
||||
#
|
||||
# Password-based login (traditional homeservers):
|
||||
password: baibot
|
||||
|
||||
# Access token login (for Matrix Authentication Service/OIDC-enabled homeservers):
|
||||
# Generate a token via: mas-cli manage issue-compatibility-token <username> [device_id]
|
||||
# access_token: null
|
||||
# device_id: null
|
||||
|
||||
# The name the bot uses as a display name and when it refers to itself.
|
||||
# Leave empty to use the default (baibot).
|
||||
name: baibot
|
||||
|
||||
# An optional path to an image file to be used as a custom avatar image.
|
||||
# - null or empty string: use the default avatar
|
||||
# - "keep": don't touch the avatar, keep whatever is already set
|
||||
# - any other value: path to a custom avatar image file
|
||||
avatar: null
|
||||
|
||||
encryption:
|
||||
# An optional passphrase to use for backing up and recovering the bot's encryption keys.
|
||||
# You can use any string here.
|
||||
@@ -39,7 +54,7 @@ room:
|
||||
access:
|
||||
# Space-separated list of MXID patterns which specify who is an admin.
|
||||
admin_patterns:
|
||||
- "@admin:synapse.127.0.0.1.nip.io"
|
||||
- "@admin:__HOMESERVER_SERVER_NAME__"
|
||||
|
||||
persistence:
|
||||
# This is unset here, because we expect the configuration to come from an environment variable (BAIBOT_PERSISTENCE_DATA_DIR_PATH).
|
||||
@@ -76,13 +91,18 @@ agents:
|
||||
# base_url: https://api.openai.com/v1
|
||||
# api_key: ""
|
||||
# text_generation:
|
||||
# model_id: gpt-5
|
||||
# model_id: gpt-5.4
|
||||
# prompt: "You are a brief, but helpful bot called {{ baibot_name }} powered by the {{ baibot_model_id }} model. The date/time of this conversation's start is: {{ baibot_conversation_start_time_utc }}."
|
||||
# temperature: 1.0
|
||||
# max_response_tokens: ~
|
||||
# # Reasoning models need to use `max_completion_tokens` instead of `max_response_tokens`.
|
||||
# # If you're dealing with a non-reasoning model, specify `max_response_tokens` and unset `max_completion_tokens`.
|
||||
# max_response_tokens: null
|
||||
# max_completion_tokens: 128000
|
||||
# max_context_tokens: 400000
|
||||
# # Built-in tools
|
||||
# tools:
|
||||
# web_search: false
|
||||
# code_interpreter: false
|
||||
# speech_to_text:
|
||||
# model_id: whisper-1
|
||||
# text_to_speech:
|
||||
@@ -91,7 +111,7 @@ agents:
|
||||
# speed: 1.0
|
||||
# response_format: opus
|
||||
# image_generation:
|
||||
# model_id: gpt-image-1
|
||||
# model_id: gpt-image-1.5
|
||||
# style: null
|
||||
# size: null
|
||||
# quality: null
|
||||
@@ -146,7 +166,7 @@ initial_global_config:
|
||||
# Space-separated list of MXID patterns which specify who can use the bot.
|
||||
# By default, we let anyone on the homeserver use the bot.
|
||||
user_patterns:
|
||||
- "@*:synapse.127.0.0.1.nip.io"
|
||||
- "@*:__HOMESERVER_SERVER_NAME__"
|
||||
|
||||
# Controls logging.
|
||||
#
|
||||
|
||||
23
etc/services/continuwuity/compose.yml
Normal file
23
etc/services/continuwuity/compose.yml
Normal file
@@ -0,0 +1,23 @@
|
||||
services:
|
||||
continuwuity:
|
||||
image: forgejo.ellis.link/continuwuation/continuwuity:v0.5.9
|
||||
user: "${UID}:${GID}"
|
||||
restart: unless-stopped
|
||||
cap_drop:
|
||||
- ALL
|
||||
read_only: true
|
||||
environment:
|
||||
CONDUWUIT_CONFIG: /etc/continuwuity/continuwuity.toml
|
||||
CONDUWUIT_DATABASE_PATH: /var/lib/continuwuity
|
||||
ports:
|
||||
- "${SERVICE_CONTINUWUITY_BIND_PORT_CLIENT_API}:6167"
|
||||
volumes:
|
||||
- ../../etc/services/continuwuity/config:/etc/continuwuity:ro
|
||||
- ./continuwuity/data:/var/lib/continuwuity
|
||||
tmpfs:
|
||||
- /tmp:rw,noexec,nosuid,size=500m
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: ${NETWORK_NAME}
|
||||
external: true
|
||||
19
etc/services/continuwuity/config/continuwuity.toml
Normal file
19
etc/services/continuwuity/config/continuwuity.toml
Normal file
@@ -0,0 +1,19 @@
|
||||
[global]
|
||||
server_name = "continuwuity.127.0.0.1.nip.io"
|
||||
|
||||
address = "0.0.0.0"
|
||||
port = 6167
|
||||
|
||||
database_path = "/var/lib/continuwuity"
|
||||
|
||||
allow_registration = true
|
||||
yes_i_am_very_very_sure_i_want_an_open_registration_server_prone_to_abuse = true
|
||||
|
||||
new_user_displayname_suffix = ""
|
||||
|
||||
max_request_size = 20_000_000
|
||||
|
||||
allow_federation = false
|
||||
trusted_servers = ["matrix.org"]
|
||||
|
||||
log = "info,state_res=warn,rocket=off,_=off,sled=off"
|
||||
48
etc/services/continuwuity/register-user.sh
Executable file
48
etc/services/continuwuity/register-user.sh
Executable file
@@ -0,0 +1,48 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
if [ $# -ne 3 ]; then
|
||||
echo "Usage: $0 <env-file> <username> <password>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ENV_FILE="$1"
|
||||
USERNAME="$2"
|
||||
PASSWORD="$3"
|
||||
|
||||
SERVER="http://$(grep '^SERVICE_CONTINUWUITY_BIND_PORT_CLIENT_API=' "${ENV_FILE}" | cut -d= -f2)"
|
||||
REGISTER_URL="${SERVER}/_matrix/client/v3/register"
|
||||
|
||||
echo "Registering user '${USERNAME}' on ${SERVER}..."
|
||||
|
||||
SESSION_RESPONSE=$(curl -s -X POST "${REGISTER_URL}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"username\": \"${USERNAME}\", \"password\": \"${PASSWORD}\"}")
|
||||
|
||||
SESSION_ID=$(echo "${SESSION_RESPONSE}" | grep -o '"session":"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||
if [ -z "${SESSION_ID}" ]; then
|
||||
echo "Error: Could not get session ID. Response: ${SESSION_RESPONSE}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Determine the required auth flow from the server response.
|
||||
# The first user requires m.login.registration_token (bootstrap token from logs).
|
||||
# Subsequent users use m.login.dummy (open registration).
|
||||
if echo "${SESSION_RESPONSE}" | grep -q 'm.login.registration_token'; then
|
||||
CONTAINER_ID=$(docker ps -q --filter name=baibot-continuwuity-continuwuity)
|
||||
REG_TOKEN=$(docker logs "${CONTAINER_ID}" 2>&1 | sed 's/\x1b\[[0-9;]*m//g' | grep 'using the registration token' | grep -oP 'registration token \K[A-Za-z0-9]+' | head -1)
|
||||
AUTH_BODY="{\"type\": \"m.login.registration_token\", \"token\": \"${REG_TOKEN}\", \"session\": \"${SESSION_ID}\"}"
|
||||
else
|
||||
AUTH_BODY="{\"type\": \"m.login.dummy\", \"session\": \"${SESSION_ID}\"}"
|
||||
fi
|
||||
|
||||
RESULT=$(curl -s -X POST "${REGISTER_URL}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"username\": \"${USERNAME}\", \"password\": \"${PASSWORD}\", \"auth\": ${AUTH_BODY}}")
|
||||
|
||||
if echo "${RESULT}" | grep -q '"user_id"'; then
|
||||
echo "Successfully registered user: $(echo "${RESULT}" | grep -o '"user_id":"[^"]*"' | cut -d'"' -f4)"
|
||||
else
|
||||
echo "Registration failed. Response: ${RESULT}"
|
||||
exit 1
|
||||
fi
|
||||
21
etc/services/element-web/compose.yml
Normal file
21
etc/services/element-web/compose.yml
Normal file
@@ -0,0 +1,21 @@
|
||||
services:
|
||||
element-web:
|
||||
image: ghcr.io/element-hq/element-web:v1.12.17
|
||||
user: "${UID}:${GID}"
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
ELEMENT_WEB_PORT: 8080
|
||||
ports:
|
||||
- "${SERVICE_ELEMENT_WEB_BIND_PORT_HTTP}:8080"
|
||||
volumes:
|
||||
- ./element-web/config.json:/app/config.json:ro
|
||||
tmpfs:
|
||||
- /var/cache/nginx:rw,mode=777
|
||||
- /var/run:rw,mode=777
|
||||
- /tmp/element-web-config:rw,mode=777
|
||||
- /etc/nginx/conf.d:rw,mode=777
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: ${NETWORK_NAME}
|
||||
external: true
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"default_hs_url": "http://synapse.127.0.0.1.nip.io:42020",
|
||||
"default_hs_url": "__HOMESERVER_CLIENT_URL__",
|
||||
"default_is_url": "https://vector.im",
|
||||
"integrations_ui_url": "https://scalar.vector.im/",
|
||||
"integrations_rest_url": "https://scalar.vector.im/api",
|
||||
@@ -3,6 +3,8 @@ SERVICE_SYNAPSE_BIND_PORT_FEDERATION_API=127.0.0.1:42028
|
||||
|
||||
SERVICE_ELEMENT_WEB_BIND_PORT_HTTP=127.0.0.1:42025
|
||||
|
||||
SERVICE_CONTINUWUITY_BIND_PORT_CLIENT_API=127.0.0.1:42030
|
||||
|
||||
SERVICE_OLLAMA_BIND_PORT_HTTP=127.0.0.1:42026
|
||||
|
||||
# See https://localai.io/basics/container/#all-in-one-images for the list of available images
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
ollama:
|
||||
image: docker.io/ollama/ollama:0.11.10
|
||||
image: docker.io/ollama/ollama:0.23.2
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${SERVICE_OLLAMA_BIND_PORT_HTTP}:11434"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
postgres:
|
||||
image: docker.io/postgres:17.6-alpine
|
||||
image: docker.io/postgres:18.3-alpine
|
||||
user: ${UID}:${GID}
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
@@ -8,12 +8,13 @@ services:
|
||||
POSTGRES_PASSWORD: synapse-password
|
||||
POSTGRES_DB: homeserver
|
||||
POSTGRES_INITDB_ARGS: --lc-collate C --lc-ctype C --encoding UTF8
|
||||
PGDATA: /data
|
||||
volumes:
|
||||
- ./postgres:/var/lib/postgresql/data
|
||||
- ./postgres:/data
|
||||
- /etc/passwd:/etc/passwd:ro
|
||||
|
||||
synapse:
|
||||
image: ghcr.io/element-hq/synapse:v1.137.0
|
||||
image: ghcr.io/element-hq/synapse:v1.152.1
|
||||
user: "${UID}:${GID}"
|
||||
restart: unless-stopped
|
||||
entrypoint: python
|
||||
@@ -22,25 +23,9 @@ services:
|
||||
- "${SERVICE_SYNAPSE_BIND_PORT_CLIENT_API}:8008"
|
||||
- "${SERVICE_SYNAPSE_BIND_PORT_FEDERATION_API}:8008"
|
||||
volumes:
|
||||
- ../../etc/services/core/synapse/config:/config:ro
|
||||
- ../../etc/services/synapse/config:/config:ro
|
||||
- ./synapse/media-store:/media-store
|
||||
|
||||
element-web:
|
||||
image: ghcr.io/element-hq/element-web:v1.11.110
|
||||
user: "${UID}:${GID}"
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
ELEMENT_WEB_PORT: 8080
|
||||
ports:
|
||||
- "${SERVICE_ELEMENT_WEB_BIND_PORT_HTTP}:8080"
|
||||
volumes:
|
||||
- ../../etc/services/core/element-web/config.json:/app/config.json:ro
|
||||
tmpfs:
|
||||
- /var/cache/nginx:rw,mode=777
|
||||
- /var/run:rw,mode=777
|
||||
- /tmp/element-web-config:rw,mode=777
|
||||
- /etc/nginx/conf.d:rw,mode=777
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: ${NETWORK_NAME}
|
||||
214
justfile
214
justfile
@@ -2,10 +2,33 @@ project_name := "baibot"
|
||||
container_image_name := "localhost/baibot"
|
||||
project_container_network := "baibot"
|
||||
|
||||
admin_username := "admin"
|
||||
admin_password := "admin"
|
||||
bot_username := "baibot"
|
||||
bot_password := "baibot"
|
||||
|
||||
homeserver := `cat var/homeserver 2>/dev/null || echo continuwuity`
|
||||
|
||||
mise_data_dir := env("MISE_DATA_DIR", justfile_directory() / "var/mise")
|
||||
mise_trusted_config_paths := justfile_directory() / "mise.toml"
|
||||
|
||||
# Show help by default
|
||||
default:
|
||||
@just --list --justfile {{ justfile() }}
|
||||
|
||||
# Selects which homeserver implementation to use (continuwuity or synapse)
|
||||
homeserver-init value:
|
||||
#!/bin/sh
|
||||
mkdir -p {{ justfile_directory() }}/var
|
||||
echo {{ value }} > {{ justfile_directory() }}/var/homeserver
|
||||
echo ""
|
||||
echo "⚠️ If you had already prepared your app configuration (var/app/local/config.yml or var/app/container/config.yml),"
|
||||
echo " you will need to update it manually or delete it and re-run the prepare step."
|
||||
echo " You should also delete var/app/local/data and/or var/app/container/data,"
|
||||
echo " as old application state is not compatible across homeserver implementations."
|
||||
echo ""
|
||||
echo "⚠️ If Element Web was already prepared, delete var/services/element-web/ to regenerate its config."
|
||||
|
||||
# Builds and runs a development binary
|
||||
run-locally *extra_args: app-local-prepare
|
||||
RUST_BACKTRACE=1 \
|
||||
@@ -65,9 +88,13 @@ docker-compose services_type *extra_args:
|
||||
-p {{ project_name }}-{{ services_type }} \
|
||||
{{ extra_args }}
|
||||
|
||||
# Runs a docker-compose command against the core services
|
||||
docker-compose-core *extra_args:
|
||||
just docker-compose core {{ extra_args }}
|
||||
# Runs a docker-compose command against the synapse services
|
||||
docker-compose-synapse *extra_args:
|
||||
just docker-compose synapse {{ extra_args }}
|
||||
|
||||
# Runs a docker-compose command against the element-web services
|
||||
docker-compose-element-web *extra_args:
|
||||
just docker-compose element-web {{ extra_args }}
|
||||
|
||||
# Runs a docker-compose command against the localai services
|
||||
docker-compose-localai *extra_args:
|
||||
@@ -77,17 +104,52 @@ docker-compose-localai *extra_args:
|
||||
docker-compose-ollama *extra_args:
|
||||
just docker-compose ollama {{ extra_args }}
|
||||
|
||||
# Runs all core dependency components (in the background)
|
||||
services-start: services-prepare (docker-compose-core "up" "-d")
|
||||
# Runs a docker-compose command against the continuwuity services
|
||||
docker-compose-continuwuity *extra_args:
|
||||
just docker-compose continuwuity {{ extra_args }}
|
||||
|
||||
# Stops all core dependency components
|
||||
services-stop: (docker-compose-core "down")
|
||||
# Runs the homeserver and Element Web (in the background)
|
||||
services-start: services-prepare
|
||||
just -f {{ justfile_directory() }}/justfile {{ homeserver }}-start
|
||||
just -f {{ justfile_directory() }}/justfile element-web-start
|
||||
|
||||
# Tails the logs for all running core services
|
||||
services-tail-logs: (docker-compose-core "logs" "-f")
|
||||
# Stops Element Web and the homeserver
|
||||
services-stop:
|
||||
just -f {{ justfile_directory() }}/justfile element-web-stop
|
||||
just -f {{ justfile_directory() }}/justfile {{ homeserver }}-stop
|
||||
|
||||
# Prepares the core services for running
|
||||
services-prepare: _prepare-var-services-env _prepare-var-services-postgres _prepare-var-services-synapse _prepare-container-network
|
||||
# Tails the logs for the homeserver and Element Web
|
||||
services-tail-logs:
|
||||
just -f {{ justfile_directory() }}/justfile {{ homeserver }}-tail-logs
|
||||
|
||||
# Prepares the homeserver and Element Web for running
|
||||
services-prepare:
|
||||
just -f {{ justfile_directory() }}/justfile {{ homeserver }}-prepare
|
||||
just -f {{ justfile_directory() }}/justfile element-web-prepare
|
||||
|
||||
# Runs Synapse (in the background)
|
||||
synapse-start: synapse-prepare (docker-compose-synapse "up" "-d")
|
||||
|
||||
# Stops Synapse
|
||||
synapse-stop: (docker-compose-synapse "down")
|
||||
|
||||
# Tails the logs for Synapse
|
||||
synapse-tail-logs: (docker-compose-synapse "logs" "-f")
|
||||
|
||||
# Prepares Synapse for running
|
||||
synapse-prepare: _prepare-var-services-env _prepare-var-services-postgres _prepare-var-services-synapse _prepare-container-network
|
||||
|
||||
# Runs Element Web (in the background)
|
||||
element-web-start: element-web-prepare (docker-compose-element-web "up" "-d")
|
||||
|
||||
# Stops Element Web
|
||||
element-web-stop: (docker-compose-element-web "down")
|
||||
|
||||
# Tails the logs for Element Web
|
||||
element-web-tail-logs: (docker-compose-element-web "logs" "-f")
|
||||
|
||||
# Prepares Element Web for running
|
||||
element-web-prepare: _prepare-var-services-env _prepare-var-services-element-web _prepare-container-network
|
||||
|
||||
# Runs LocalAI (in the background)
|
||||
localai-start: localai-prepare (docker-compose-localai "up" "-d")
|
||||
@@ -113,6 +175,27 @@ ollama-tail-logs: (docker-compose-ollama "logs" "-f")
|
||||
# Prepares Ollama for running
|
||||
ollama-prepare: _prepare-var-services-env _prepare-var-services-ollama _prepare-container-network
|
||||
|
||||
# Runs Continuwuity (in the background)
|
||||
continuwuity-start: continuwuity-prepare (docker-compose-continuwuity "up" "-d")
|
||||
|
||||
# Stops Continuwuity
|
||||
continuwuity-stop: (docker-compose-continuwuity "down")
|
||||
|
||||
# Tails the logs for Continuwuity
|
||||
continuwuity-tail-logs: (docker-compose-continuwuity "logs" "-f")
|
||||
|
||||
# Prepares Continuwuity for running
|
||||
continuwuity-prepare: _prepare-var-services-env _prepare-var-services-continuwuity _prepare-container-network
|
||||
|
||||
# Registers a user on Continuwuity via the Matrix Client-Server API
|
||||
continuwuity-register-user username password:
|
||||
{{ justfile_directory() }}/etc/services/continuwuity/register-user.sh {{ justfile_directory() }}/var/services/env {{ username }} {{ password }}
|
||||
|
||||
# Prepares the Continuwuity user accounts
|
||||
continuwuity-users-prepare: continuwuity-prepare
|
||||
just -f {{ justfile_directory() }}/justfile continuwuity-register-user "{{ admin_username }}" "{{ admin_password }}"
|
||||
just -f {{ justfile_directory() }}/justfile continuwuity-register-user "{{ bot_username }}" "{{ bot_password }}"
|
||||
|
||||
# Pulls an Ollama model
|
||||
ollama-pull-model model_id:
|
||||
just -f {{ justfile_directory() }}/justfile docker-compose-ollama \
|
||||
@@ -126,16 +209,20 @@ app-local-prepare: _prepare-var-app-local-config_yml _prepare-var-app-local-data
|
||||
app-container-prepare: _prepare-var-app-container-config_yml _prepare-var-app-container-data
|
||||
|
||||
# Prepares the user accounts
|
||||
users-prepare: services-prepare
|
||||
just -f {{ justfile_directory() }}/justfile synapse-register-admin-user "admin" "admin"
|
||||
just -f {{ justfile_directory() }}/justfile synapse-register-regular-user "baibot" "baibot"
|
||||
users-prepare:
|
||||
just -f {{ justfile_directory() }}/justfile {{ homeserver }}-users-prepare
|
||||
|
||||
# Prepares the Synapse user accounts
|
||||
synapse-users-prepare: synapse-prepare
|
||||
just -f {{ justfile_directory() }}/justfile synapse-register-admin-user "{{ admin_username }}" "{{ admin_password }}"
|
||||
just -f {{ justfile_directory() }}/justfile synapse-register-regular-user "{{ bot_username }}" "{{ bot_password }}"
|
||||
|
||||
# Starts a Postgres CLI (psql)
|
||||
postgres-cli: services-prepare (docker-compose-core "exec" "postgres" "/bin/sh" "-c" "'PGUSER=synapse PGPASSWORD=synapse-password PGDATABASE=homeserver psql -h postgres'")
|
||||
postgres-cli: synapse-prepare (docker-compose-synapse "exec" "postgres" "/bin/sh" "-c" "'PGUSER=synapse PGPASSWORD=synapse-password PGDATABASE=homeserver psql -h postgres'")
|
||||
|
||||
# Creates an administrator user
|
||||
synapse-register-admin-user username password: services-prepare
|
||||
just -f {{ justfile_directory() }}/justfile docker-compose-core \
|
||||
# Creates an administrator user on Synapse
|
||||
synapse-register-admin-user username password: synapse-prepare
|
||||
just -f {{ justfile_directory() }}/justfile docker-compose-synapse \
|
||||
exec synapse \
|
||||
register_new_matrix_user \
|
||||
--admin \
|
||||
@@ -144,9 +231,9 @@ synapse-register-admin-user username password: services-prepare
|
||||
-c /config/homeserver.yaml \
|
||||
http://localhost:8008
|
||||
|
||||
# Create a regular user
|
||||
synapse-register-regular-user username password: services-prepare
|
||||
just -f {{ justfile_directory() }}/justfile docker-compose-core \
|
||||
# Creates a regular user on Synapse
|
||||
synapse-register-regular-user username password: synapse-prepare
|
||||
just -f {{ justfile_directory() }}/justfile docker-compose-synapse \
|
||||
exec synapse \
|
||||
register_new_matrix_user \
|
||||
--no-admin \
|
||||
@@ -159,6 +246,44 @@ synapse-register-regular-user username password: services-prepare
|
||||
clippy *extra_args:
|
||||
cargo clippy {{ extra_args }}
|
||||
|
||||
# Checks that the code compiles without building
|
||||
check:
|
||||
cargo check
|
||||
|
||||
# Invokes mise with the project-local data directory
|
||||
mise *args: _ensure_mise_data_directory
|
||||
#!/bin/sh
|
||||
export MISE_DATA_DIR="{{ mise_data_dir }}"
|
||||
export MISE_TRUSTED_CONFIG_PATHS="{{ mise_trusted_config_paths }}"
|
||||
mise {{ args }}
|
||||
|
||||
# Runs prek (pre-commit hooks manager) with the given arguments
|
||||
prek *args: _ensure_mise_tools_installed
|
||||
@just --justfile {{ justfile() }} mise exec -- prek {{ args }}
|
||||
|
||||
# Runs pre-commit hooks on staged files
|
||||
prek-run-on-staged *args: _ensure_mise_tools_installed
|
||||
@just --justfile {{ justfile() }} mise exec -- prek run {{ args }}
|
||||
|
||||
# Runs pre-commit hooks on all files
|
||||
prek-run-on-all *args: _ensure_mise_tools_installed
|
||||
@just --justfile {{ justfile() }} mise exec -- prek run --all-files {{ args }}
|
||||
|
||||
# Installs the git pre-commit hook (runs prek automatically before each commit)
|
||||
prek-install-git-pre-commit-hook: _ensure_mise_tools_installed
|
||||
@just --justfile {{ justfile() }} mise exec -- prek install
|
||||
|
||||
# Internal - ensures var/mise directory exists
|
||||
_ensure_mise_data_directory:
|
||||
#!/bin/sh
|
||||
if [ ! -d "{{ mise_data_dir }}" ]; then
|
||||
mkdir -p "{{ mise_data_dir }}"
|
||||
fi
|
||||
|
||||
# Internal - ensures mise tools are installed
|
||||
_ensure_mise_tools_installed: _ensure_mise_data_directory
|
||||
@just --justfile {{ justfile() }} mise install --quiet
|
||||
|
||||
_prepare-var-services-env:
|
||||
#!/bin/sh
|
||||
cd {{ justfile_directory() }};
|
||||
@@ -188,6 +313,22 @@ _prepare-var-services-synapse:
|
||||
mkdir -p var/services/synapse/media-store
|
||||
fi
|
||||
|
||||
_prepare-var-services-element-web:
|
||||
#!/bin/sh
|
||||
cd {{ justfile_directory() }};
|
||||
|
||||
if [ ! -f var/services/element-web/config.json ]; then
|
||||
mkdir -p var/services/element-web
|
||||
cp {{ justfile_directory() }}/etc/services/element-web/config.json.dist var/services/element-web/config.json
|
||||
|
||||
homeserver="{{ homeserver }}"
|
||||
if [ "$homeserver" = "continuwuity" ]; then
|
||||
sed --in-place 's|__HOMESERVER_CLIENT_URL__|http://continuwuity.127.0.0.1.nip.io:42030|g' var/services/element-web/config.json
|
||||
elif [ "$homeserver" = "synapse" ]; then
|
||||
sed --in-place 's|__HOMESERVER_CLIENT_URL__|http://synapse.127.0.0.1.nip.io:42020|g' var/services/element-web/config.json
|
||||
fi
|
||||
fi
|
||||
|
||||
_prepare-var-services-ollama:
|
||||
#!/bin/sh
|
||||
cd {{ justfile_directory() }};
|
||||
@@ -196,6 +337,14 @@ _prepare-var-services-ollama:
|
||||
mkdir -p var/services/ollama
|
||||
fi
|
||||
|
||||
_prepare-var-services-continuwuity:
|
||||
#!/bin/sh
|
||||
cd {{ justfile_directory() }};
|
||||
|
||||
if [ ! -f var/services/continuwuity ]; then
|
||||
mkdir -p var/services/continuwuity/data
|
||||
fi
|
||||
|
||||
_prepare-var-services-localai:
|
||||
#!/bin/sh
|
||||
cd {{ justfile_directory() }};
|
||||
@@ -219,6 +368,15 @@ _prepare-var-app-local-config_yml:
|
||||
if [ ! -f var/app/local/config.yml ]; then
|
||||
mkdir -p var/app/local
|
||||
cp {{ justfile_directory() }}/etc/app/config.yml.dist var/app/local/config.yml
|
||||
|
||||
homeserver="{{ homeserver }}"
|
||||
if [ "$homeserver" = "continuwuity" ]; then
|
||||
sed --in-place 's/__HOMESERVER_SERVER_NAME__/continuwuity.127.0.0.1.nip.io/g' var/app/local/config.yml
|
||||
sed --in-place 's|__HOMESERVER_URL__|http://continuwuity.127.0.0.1.nip.io:42030|g' var/app/local/config.yml
|
||||
elif [ "$homeserver" = "synapse" ]; then
|
||||
sed --in-place 's/__HOMESERVER_SERVER_NAME__/synapse.127.0.0.1.nip.io/g' var/app/local/config.yml
|
||||
sed --in-place 's|__HOMESERVER_URL__|http://synapse.127.0.0.1.nip.io:42020|g' var/app/local/config.yml
|
||||
fi
|
||||
fi
|
||||
|
||||
_prepare-var-app-local-data:
|
||||
@@ -236,7 +394,18 @@ _prepare-var-app-container-config_yml:
|
||||
if [ ! -f var/app/container/config.yml ]; then
|
||||
mkdir -p var/app/container
|
||||
cp {{ justfile_directory() }}/etc/app/config.yml.dist var/app/container/config.yml
|
||||
sed --in-place 's/synapse.127.0.0.1.nip.io:42020/synapse:8008/g' var/app/container/config.yml
|
||||
|
||||
homeserver="{{ homeserver }}"
|
||||
if [ "$homeserver" = "continuwuity" ]; then
|
||||
sed --in-place 's/__HOMESERVER_SERVER_NAME__/continuwuity.127.0.0.1.nip.io/g' var/app/container/config.yml
|
||||
sed --in-place 's|__HOMESERVER_URL__|http://continuwuity.127.0.0.1.nip.io:42030|g' var/app/container/config.yml
|
||||
sed --in-place 's/continuwuity.127.0.0.1.nip.io:42030/continuwuity:6167/g' var/app/container/config.yml
|
||||
elif [ "$homeserver" = "synapse" ]; then
|
||||
sed --in-place 's/__HOMESERVER_SERVER_NAME__/synapse.127.0.0.1.nip.io/g' var/app/container/config.yml
|
||||
sed --in-place 's|__HOMESERVER_URL__|http://synapse.127.0.0.1.nip.io:42020|g' var/app/container/config.yml
|
||||
sed --in-place 's/synapse.127.0.0.1.nip.io:42020/synapse:8008/g' var/app/container/config.yml
|
||||
fi
|
||||
|
||||
sed --in-place 's/127.0.0.1:42026/ollama:11434/g' var/app/container/config.yml
|
||||
sed --in-place 's/127.0.0.1:42027/localai:8080/g' var/app/container/config.yml
|
||||
fi
|
||||
@@ -248,4 +417,3 @@ _prepare-var-app-container-data:
|
||||
if [ ! -f var/app/container/data ]; then
|
||||
mkdir -p var/app/container/data
|
||||
fi
|
||||
|
||||
|
||||
6
mise.toml
Normal file
6
mise.toml
Normal file
@@ -0,0 +1,6 @@
|
||||
[tools]
|
||||
prek = "0.3.13"
|
||||
|
||||
[settings]
|
||||
# Disable automatic trust prompts - we trust this config
|
||||
yes = true
|
||||
9
renovate.json
Normal file
9
renovate.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"config:recommended"
|
||||
],
|
||||
"labels": [
|
||||
"dependencies"
|
||||
]
|
||||
}
|
||||
4
rust-toolchain.toml
Normal file
4
rust-toolchain.toml
Normal file
@@ -0,0 +1,4 @@
|
||||
[toolchain]
|
||||
channel = "1.95.0"
|
||||
components = ["rustfmt", "clippy"]
|
||||
profile = "default"
|
||||
@@ -33,11 +33,11 @@ pub struct AgentDefinition {
|
||||
)]
|
||||
pub provider: AgentProvider,
|
||||
|
||||
pub config: serde_yaml::Value,
|
||||
pub config: serde_yaml_ng::Value,
|
||||
}
|
||||
|
||||
impl AgentDefinition {
|
||||
pub fn new(id: String, provider: AgentProvider, config: serde_yaml::Value) -> Self {
|
||||
pub fn new(id: String, provider: AgentProvider, config: serde_yaml_ng::Value) -> Self {
|
||||
Self {
|
||||
id,
|
||||
provider,
|
||||
|
||||
@@ -15,7 +15,7 @@ pub enum Error {
|
||||
// Contains the error from the constructor function
|
||||
ConstructionFailed(anyhow::Error),
|
||||
// Contains the error from the YAML deserialization function
|
||||
Yaml(serde_yaml::Error),
|
||||
Yaml(serde_yaml_ng::Error),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
@@ -69,7 +69,7 @@ pub(super) fn create(
|
||||
pub fn create_from_provider_and_yaml_value_config(
|
||||
provider: &AgentProvider,
|
||||
identifier: &PublicIdentifier,
|
||||
config: serde_yaml::Value,
|
||||
config: serde_yaml_ng::Value,
|
||||
) -> Result<AgentInstance> {
|
||||
let definition = AgentDefinition::new(identifier.prefixless(), provider.to_owned(), config);
|
||||
|
||||
@@ -79,7 +79,7 @@ pub fn create_from_provider_and_yaml_value_config(
|
||||
fn create_controller_from_provider_and_json_value_config(
|
||||
agent_id: &str,
|
||||
provider: &AgentProvider,
|
||||
config: serde_yaml::Value,
|
||||
config: serde_yaml_ng::Value,
|
||||
) -> Result<ControllerType> {
|
||||
match provider {
|
||||
AgentProvider::Anthropic => {
|
||||
@@ -112,43 +112,43 @@ fn create_controller_from_provider_and_json_value_config(
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default_config_for_provider(provider: &AgentProvider) -> serde_yaml::Value {
|
||||
pub fn default_config_for_provider(provider: &AgentProvider) -> serde_yaml_ng::Value {
|
||||
match provider {
|
||||
AgentProvider::Anthropic => {
|
||||
let config = super::provider::anthropic::default_config();
|
||||
serde_yaml::to_value(config).expect("Failed to serialize config")
|
||||
serde_yaml_ng::to_value(config).expect("Failed to serialize config")
|
||||
}
|
||||
AgentProvider::Groq => {
|
||||
let config = super::provider::groq::default_config();
|
||||
serde_yaml::to_value(config).expect("Failed to serialize config")
|
||||
serde_yaml_ng::to_value(config).expect("Failed to serialize config")
|
||||
}
|
||||
AgentProvider::LocalAI => {
|
||||
let config = super::provider::localai::default_config();
|
||||
serde_yaml::to_value(config).expect("Failed to serialize config")
|
||||
serde_yaml_ng::to_value(config).expect("Failed to serialize config")
|
||||
}
|
||||
AgentProvider::Mistral => {
|
||||
let config = super::provider::mistral::default_config();
|
||||
serde_yaml::to_value(config).expect("Failed to serialize config")
|
||||
serde_yaml_ng::to_value(config).expect("Failed to serialize config")
|
||||
}
|
||||
AgentProvider::Ollama => {
|
||||
let config = super::provider::ollama::default_config();
|
||||
serde_yaml::to_value(config).expect("Failed to serialize config")
|
||||
serde_yaml_ng::to_value(config).expect("Failed to serialize config")
|
||||
}
|
||||
AgentProvider::OpenAI => {
|
||||
let config = super::provider::openai::default_config();
|
||||
serde_yaml::to_value(config).expect("Failed to serialize config")
|
||||
serde_yaml_ng::to_value(config).expect("Failed to serialize config")
|
||||
}
|
||||
AgentProvider::OpenAICompat => {
|
||||
let config = super::provider::openai_compat::default_config();
|
||||
serde_yaml::to_value(config).expect("Failed to serialize config")
|
||||
serde_yaml_ng::to_value(config).expect("Failed to serialize config")
|
||||
}
|
||||
AgentProvider::OpenRouter => {
|
||||
let config = super::provider::openrouter::default_config();
|
||||
serde_yaml::to_value(config).expect("Failed to serialize config")
|
||||
serde_yaml_ng::to_value(config).expect("Failed to serialize config")
|
||||
}
|
||||
AgentProvider::TogetherAI => {
|
||||
let config = super::provider::togetherai::default_config();
|
||||
serde_yaml::to_value(config).expect("Failed to serialize config")
|
||||
serde_yaml_ng::to_value(config).expect("Failed to serialize config")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@ impl ControllerTrait for Controller {
|
||||
|
||||
let messages = vec![LLMMessage {
|
||||
author: LLMAuthor::User,
|
||||
sender_id: None,
|
||||
content: LLMMessageContent::Text("Hello!".to_string()),
|
||||
timestamp: chrono::Utc::now(),
|
||||
}];
|
||||
@@ -108,6 +109,7 @@ impl ControllerTrait for Controller {
|
||||
} else {
|
||||
Some(LLMMessage {
|
||||
author: LLMAuthor::Prompt,
|
||||
sender_id: None,
|
||||
content: LLMMessageContent::Text(prompt_text),
|
||||
timestamp: chrono::Utc::now(),
|
||||
})
|
||||
@@ -146,10 +148,10 @@ impl ControllerTrait for Controller {
|
||||
.temperature_override
|
||||
.unwrap_or(text_generation_config.temperature);
|
||||
|
||||
if let Some(prompt_message) = prompt_message {
|
||||
if let LLMMessageContent::Text(text) = &prompt_message.content {
|
||||
request.system = text.clone();
|
||||
}
|
||||
if let Some(prompt_message) = prompt_message
|
||||
&& let LLMMessageContent::Text(text) = &prompt_message.content
|
||||
{
|
||||
request.system = text.clone();
|
||||
}
|
||||
|
||||
request.model = text_generation_config.model_id.clone();
|
||||
|
||||
@@ -12,12 +12,12 @@ use super::controller::ControllerType;
|
||||
|
||||
pub fn create_controller_from_yaml_value_config(
|
||||
agent_id: &str,
|
||||
config: serde_yaml::Value,
|
||||
config: serde_yaml_ng::Value,
|
||||
) -> AgentInstantiationResult<ControllerType> {
|
||||
let config = match &config {
|
||||
serde_yaml::Value::Mapping(_) => {
|
||||
serde_yaml_ng::Value::Mapping(_) => {
|
||||
let config: Config =
|
||||
serde_yaml::from_value(config).map_err(AgentInstantiationError::Yaml)?;
|
||||
serde_yaml_ng::from_value(config).map_err(AgentInstantiationError::Yaml)?;
|
||||
|
||||
config
|
||||
.validate()
|
||||
|
||||
@@ -28,6 +28,13 @@ pub(super) fn create_anthropic_message_request(llm_messages: Vec<LLMMessage>) ->
|
||||
},
|
||||
}]
|
||||
}
|
||||
LLMMessageContent::File(file_details) => {
|
||||
tracing::warn!(
|
||||
"The Anthropic provider's library does not support file/document content. This file message ({}) will be skipped.",
|
||||
file_details.filename(),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let message = Message { role, content };
|
||||
|
||||
@@ -69,6 +69,7 @@ impl AgentProvider {
|
||||
models_list_url: Some("https://docs.anthropic.com/en/docs/about-claude/models"),
|
||||
supported_purposes: vec![AgentPurpose::TextGeneration],
|
||||
text_generation_supports_vision: true,
|
||||
text_generation_supports_tools: false,
|
||||
},
|
||||
Self::Groq => AgentProviderInfo {
|
||||
id: Self::Groq.to_static_str(),
|
||||
@@ -80,11 +81,12 @@ impl AgentProvider {
|
||||
models_list_url: Some("https://console.groq.com/docs/models"),
|
||||
supported_purposes: vec![AgentPurpose::TextGeneration, AgentPurpose::SpeechToText],
|
||||
text_generation_supports_vision: false,
|
||||
text_generation_supports_tools: false,
|
||||
},
|
||||
Self::LocalAI => AgentProviderInfo {
|
||||
id: Self::LocalAI.to_static_str(),
|
||||
name: "LocalAI",
|
||||
description: "LocalAI is the free, Open Source OpenAI alternative. LocalAI act as a drop-in replacement REST API that’s compatible with OpenAI API specifications for local inferencing. It allows you to run LLMs, generate images, audio (and not only) locally or on-prem with consumer grade hardware, supporting multiple model families and architectures.",
|
||||
description: "LocalAI is the free, Open Source OpenAI alternative. LocalAI act as a drop-in replacement REST API that's compatible with OpenAI API specifications for local inferencing. It allows you to run LLMs, generate images, audio (and not only) locally or on-prem with consumer grade hardware, supporting multiple model families and architectures.",
|
||||
homepage_url: Some("https://localai.io/"),
|
||||
wiki_url: None,
|
||||
sign_up_url: None,
|
||||
@@ -95,6 +97,7 @@ impl AgentProvider {
|
||||
AgentPurpose::SpeechToText,
|
||||
],
|
||||
text_generation_supports_vision: false,
|
||||
text_generation_supports_tools: false,
|
||||
},
|
||||
Self::Mistral => AgentProviderInfo {
|
||||
id: Self::Mistral.to_static_str(),
|
||||
@@ -106,6 +109,7 @@ impl AgentProvider {
|
||||
models_list_url: Some("https://docs.mistral.ai/getting-started/models/"),
|
||||
supported_purposes: vec![AgentPurpose::TextGeneration],
|
||||
text_generation_supports_vision: false,
|
||||
text_generation_supports_tools: false,
|
||||
},
|
||||
Self::Ollama => AgentProviderInfo {
|
||||
id: Self::Ollama.to_static_str(),
|
||||
@@ -117,6 +121,7 @@ impl AgentProvider {
|
||||
models_list_url: Some("https://ollama.com/library"),
|
||||
supported_purposes: vec![AgentPurpose::TextGeneration],
|
||||
text_generation_supports_vision: false,
|
||||
text_generation_supports_tools: false,
|
||||
},
|
||||
Self::OpenAI => AgentProviderInfo {
|
||||
id: Self::OpenAI.to_static_str(),
|
||||
@@ -133,6 +138,7 @@ impl AgentProvider {
|
||||
AgentPurpose::SpeechToText,
|
||||
],
|
||||
text_generation_supports_vision: true,
|
||||
text_generation_supports_tools: true,
|
||||
},
|
||||
Self::OpenAICompat => AgentProviderInfo {
|
||||
id: Self::OpenAICompat.to_static_str(),
|
||||
@@ -149,6 +155,7 @@ impl AgentProvider {
|
||||
AgentPurpose::SpeechToText,
|
||||
],
|
||||
text_generation_supports_vision: false,
|
||||
text_generation_supports_tools: false,
|
||||
},
|
||||
Self::OpenRouter => AgentProviderInfo {
|
||||
id: Self::OpenRouter.to_static_str(),
|
||||
@@ -160,6 +167,7 @@ impl AgentProvider {
|
||||
models_list_url: Some("https://openrouter.ai/models"),
|
||||
supported_purposes: vec![AgentPurpose::TextGeneration],
|
||||
text_generation_supports_vision: false,
|
||||
text_generation_supports_tools: false,
|
||||
},
|
||||
Self::TogetherAI => AgentProviderInfo {
|
||||
id: Self::TogetherAI.to_static_str(),
|
||||
@@ -171,6 +179,7 @@ impl AgentProvider {
|
||||
models_list_url: Some("https://api.together.xyz/models"),
|
||||
supported_purposes: vec![AgentPurpose::TextGeneration],
|
||||
text_generation_supports_vision: false,
|
||||
text_generation_supports_tools: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -192,4 +201,5 @@ pub struct AgentProviderInfo {
|
||||
pub models_list_url: Option<&'static str>,
|
||||
pub supported_purposes: Vec<AgentPurpose>,
|
||||
pub text_generation_supports_vision: bool,
|
||||
pub text_generation_supports_tools: bool,
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ use mxlink::mime;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ImageGenerationParams {
|
||||
pub size_override: Option<String>,
|
||||
pub smallest_size_possible: bool,
|
||||
|
||||
pub cheaper_model_switching_allowed: bool,
|
||||
|
||||
@@ -10,8 +10,8 @@ pub struct ImageGenerationParams {
|
||||
}
|
||||
|
||||
impl ImageGenerationParams {
|
||||
pub fn with_size_override(mut self, value: Option<String>) -> Self {
|
||||
self.size_override = value;
|
||||
pub fn with_smallest_size_possible(mut self, value: bool) -> Self {
|
||||
self.smallest_size_possible = value;
|
||||
self
|
||||
}
|
||||
|
||||
@@ -56,12 +56,8 @@ impl ImageSource {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ImageSource> for async_openai::types::ImageInput {
|
||||
impl From<ImageSource> for async_openai::types::images::ImageInput {
|
||||
fn from(value: ImageSource) -> Self {
|
||||
async_openai::types::ImageInput::from_vec_u8(
|
||||
value.filename,
|
||||
value.bytes,
|
||||
value.mime_type.to_string(),
|
||||
)
|
||||
async_openai::types::images::ImageInput::from_vec_u8(value.filename, value.bytes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::OPENAI_IMAGE_MODEL_GPT_IMAGE_1;
|
||||
use super::OPENAI_IMAGE_MODEL_GPT_IMAGE_1_DOT_5;
|
||||
use crate::agent::{default_prompt, provider::ConfigTrait};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -64,6 +64,9 @@ pub struct TextGenerationConfig {
|
||||
|
||||
#[serde(default)]
|
||||
pub max_context_tokens: u32,
|
||||
|
||||
#[serde(default)]
|
||||
pub tools: ToolsConfig,
|
||||
}
|
||||
|
||||
impl Default for TextGenerationConfig {
|
||||
@@ -75,12 +78,22 @@ impl Default for TextGenerationConfig {
|
||||
max_response_tokens: None,
|
||||
max_completion_tokens: Some(128_000),
|
||||
max_context_tokens: 400_000,
|
||||
tools: ToolsConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_text_model_id() -> String {
|
||||
"gpt-5".to_owned()
|
||||
"gpt-5.4".to_owned()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ToolsConfig {
|
||||
#[serde(default)]
|
||||
pub web_search: bool,
|
||||
|
||||
#[serde(default)]
|
||||
pub code_interpreter: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -104,16 +117,16 @@ fn default_speech_to_text_model_id() -> String {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TextToSpeechConfig {
|
||||
#[serde(default = "default_text_to_speech_model_id")]
|
||||
pub model_id: async_openai::types::SpeechModel,
|
||||
pub model_id: async_openai::types::audio::SpeechModel,
|
||||
|
||||
#[serde(default = "default_text_to_speech_voice")]
|
||||
pub voice: async_openai::types::Voice,
|
||||
pub voice: async_openai::types::audio::Voice,
|
||||
|
||||
#[serde(default = "default_text_to_speech_speed")]
|
||||
pub speed: f32,
|
||||
|
||||
#[serde(default = "default_text_to_speech_response_format")]
|
||||
pub response_format: async_openai::types::SpeechResponseFormat,
|
||||
pub response_format: async_openai::types::audio::SpeechResponseFormat,
|
||||
}
|
||||
|
||||
impl Default for TextToSpeechConfig {
|
||||
@@ -127,22 +140,22 @@ impl Default for TextToSpeechConfig {
|
||||
}
|
||||
}
|
||||
|
||||
fn default_text_to_speech_model_id() -> async_openai::types::SpeechModel {
|
||||
async_openai::types::SpeechModel::Tts1Hd
|
||||
fn default_text_to_speech_model_id() -> async_openai::types::audio::SpeechModel {
|
||||
async_openai::types::audio::SpeechModel::Tts1Hd
|
||||
}
|
||||
|
||||
fn default_text_to_speech_voice() -> async_openai::types::Voice {
|
||||
async_openai::types::Voice::Onyx
|
||||
fn default_text_to_speech_voice() -> async_openai::types::audio::Voice {
|
||||
async_openai::types::audio::Voice::Onyx
|
||||
}
|
||||
|
||||
fn default_text_to_speech_speed() -> f32 {
|
||||
1.0
|
||||
}
|
||||
|
||||
fn default_text_to_speech_response_format() -> async_openai::types::SpeechResponseFormat {
|
||||
fn default_text_to_speech_response_format() -> async_openai::types::audio::SpeechResponseFormat {
|
||||
// The API defaults to mp3, but we prefer Opus because it's smaller.
|
||||
// Our clients should all have support for it.
|
||||
async_openai::types::SpeechResponseFormat::Opus
|
||||
async_openai::types::audio::SpeechResponseFormat::Opus
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -150,19 +163,19 @@ pub struct ImageGenerationConfig {
|
||||
pub model_id: String,
|
||||
|
||||
#[serde(default = "default_image_style")]
|
||||
pub style: Option<async_openai::types::ImageStyle>,
|
||||
pub style: Option<async_openai::types::images::ImageStyle>,
|
||||
|
||||
#[serde(default = "default_image_size")]
|
||||
pub size: Option<async_openai::types::ImageSize>,
|
||||
pub size: Option<async_openai::types::images::ImageSize>,
|
||||
|
||||
#[serde(default = "default_image_quality")]
|
||||
pub quality: Option<async_openai::types::ImageQuality>,
|
||||
pub quality: Option<async_openai::types::images::ImageQuality>,
|
||||
}
|
||||
|
||||
impl Default for ImageGenerationConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
model_id: OPENAI_IMAGE_MODEL_GPT_IMAGE_1.to_owned(),
|
||||
model_id: OPENAI_IMAGE_MODEL_GPT_IMAGE_1_DOT_5.to_owned(),
|
||||
style: default_image_style(),
|
||||
size: default_image_size(),
|
||||
quality: default_image_quality(),
|
||||
@@ -173,23 +186,28 @@ impl Default for ImageGenerationConfig {
|
||||
impl ImageGenerationConfig {
|
||||
pub fn model_id_as_openai_image_model(
|
||||
&self,
|
||||
) -> Result<async_openai::types::ImageModel, String> {
|
||||
) -> Result<async_openai::types::images::ImageModel, String> {
|
||||
match self.model_id.as_str() {
|
||||
"dall-e-2" => Ok(async_openai::types::ImageModel::DallE2),
|
||||
"dall-e-3" => Ok(async_openai::types::ImageModel::DallE3),
|
||||
other => Ok(async_openai::types::ImageModel::Other(other.to_owned())),
|
||||
"dall-e-2" => Ok(async_openai::types::images::ImageModel::DallE2),
|
||||
"dall-e-3" => Ok(async_openai::types::images::ImageModel::DallE3),
|
||||
"gpt-image-1" => Ok(async_openai::types::images::ImageModel::GptImage1),
|
||||
"gpt-image-1.5" => Ok(async_openai::types::images::ImageModel::GptImage1dot5),
|
||||
"gpt-image-1-mini" => Ok(async_openai::types::images::ImageModel::GptImage1Mini),
|
||||
other => Ok(async_openai::types::images::ImageModel::Other(
|
||||
other.to_owned(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_image_style() -> Option<async_openai::types::ImageStyle> {
|
||||
fn default_image_style() -> Option<async_openai::types::images::ImageStyle> {
|
||||
None
|
||||
}
|
||||
|
||||
fn default_image_size() -> Option<async_openai::types::ImageSize> {
|
||||
fn default_image_size() -> Option<async_openai::types::images::ImageSize> {
|
||||
None
|
||||
}
|
||||
|
||||
fn default_image_quality() -> Option<async_openai::types::ImageQuality> {
|
||||
fn default_image_quality() -> Option<async_openai::types::images::ImageQuality> {
|
||||
None
|
||||
}
|
||||
|
||||
@@ -4,9 +4,15 @@ use async_openai::{
|
||||
Client as OpenAIClient,
|
||||
config::OpenAIConfig,
|
||||
types::{
|
||||
ChatCompletionRequestMessage, CreateChatCompletionRequestArgs, CreateImageEditRequestArgs,
|
||||
CreateImageRequestArgs, CreateSpeechRequestArgs, CreateTranscriptionRequestArgs,
|
||||
DallE2ImageSize, Image, ImageModel, ImageResponseFormat,
|
||||
audio::{AudioInput, CreateSpeechRequestArgs, CreateTranscriptionRequestArgs},
|
||||
images::{
|
||||
CreateImageEditRequestArgs, CreateImageRequestArgs, Image, ImageInput, ImageModel,
|
||||
ImageResponseFormat,
|
||||
},
|
||||
responses::{
|
||||
CodeInterpreterContainerAuto, CodeInterpreterTool, CodeInterpreterToolContainer,
|
||||
CreateResponseArgs, OutputItem, OutputMessageContent, Tool, WebSearchTool,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -25,12 +31,9 @@ use crate::{
|
||||
use crate::{
|
||||
agent::{
|
||||
AgentPurpose,
|
||||
provider::{
|
||||
entity::{
|
||||
ImageEditResult, ImageGenerationResult, ImageSource, PingResult,
|
||||
TextToSpeechParams, TextToSpeechResult,
|
||||
},
|
||||
openai::utils::convert_string_to_enum,
|
||||
provider::entity::{
|
||||
ImageEditResult, ImageGenerationResult, ImageSource, PingResult, TextToSpeechParams,
|
||||
TextToSpeechResult,
|
||||
},
|
||||
},
|
||||
strings,
|
||||
@@ -38,8 +41,6 @@ use crate::{
|
||||
|
||||
use super::config::Config;
|
||||
|
||||
use super::OPENAI_IMAGE_MODEL_GPT_IMAGE_1;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Controller {
|
||||
config: Config,
|
||||
@@ -66,6 +67,7 @@ impl ControllerTrait for Controller {
|
||||
|
||||
let messages = vec![LLMMessage {
|
||||
author: LLMAuthor::User,
|
||||
sender_id: None,
|
||||
content: LLMMessageContent::Text("Hello!".to_string()),
|
||||
timestamp: chrono::Utc::now(),
|
||||
}];
|
||||
@@ -103,6 +105,7 @@ impl ControllerTrait for Controller {
|
||||
} else {
|
||||
Some(LLMMessage {
|
||||
author: LLMAuthor::Prompt,
|
||||
sender_id: None,
|
||||
content: LLMMessageContent::Text(prompt_text),
|
||||
timestamp: chrono::Utc::now(),
|
||||
})
|
||||
@@ -128,28 +131,45 @@ impl ControllerTrait for Controller {
|
||||
conversation_messages.insert(0, prompt_message);
|
||||
}
|
||||
|
||||
let openai_conversation_messages: Vec<ChatCompletionRequestMessage> =
|
||||
super::utils::convert_llm_messages_to_openai_messages(conversation_messages);
|
||||
let input =
|
||||
super::utils::convert_llm_messages_to_openai_response_input(conversation_messages);
|
||||
|
||||
let messages_count = openai_conversation_messages.len();
|
||||
let messages_count = match &input {
|
||||
async_openai::types::responses::InputParam::Items(items) => items.len(),
|
||||
_ => 1,
|
||||
};
|
||||
|
||||
let temperature = params
|
||||
.temperature_override
|
||||
.unwrap_or(text_generation_config.temperature);
|
||||
|
||||
let mut request_builder = CreateChatCompletionRequestArgs::default();
|
||||
let mut request_builder = CreateResponseArgs::default();
|
||||
|
||||
request_builder
|
||||
.model(&text_generation_config.model_id)
|
||||
.temperature(temperature)
|
||||
.messages(openai_conversation_messages);
|
||||
.input(input);
|
||||
|
||||
if let Some(max_response_tokens) = text_generation_config.max_response_tokens {
|
||||
request_builder.max_tokens(max_response_tokens);
|
||||
let mut tools = Vec::new();
|
||||
if text_generation_config.tools.web_search {
|
||||
tools.push(Tool::WebSearch(WebSearchTool::default()));
|
||||
}
|
||||
if text_generation_config.tools.code_interpreter {
|
||||
tools.push(Tool::CodeInterpreter(CodeInterpreterTool {
|
||||
container: CodeInterpreterToolContainer::Auto(
|
||||
CodeInterpreterContainerAuto::default(),
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
if let Some(max_completion_tokens) = text_generation_config.max_completion_tokens {
|
||||
request_builder.max_completion_tokens(max_completion_tokens);
|
||||
if !tools.is_empty() {
|
||||
request_builder.tools(tools);
|
||||
}
|
||||
|
||||
if let Some(max_response_tokens) = text_generation_config.max_response_tokens {
|
||||
request_builder.max_output_tokens(max_response_tokens);
|
||||
} else if let Some(max_completion_tokens) = text_generation_config.max_completion_tokens {
|
||||
request_builder.max_output_tokens(max_completion_tokens);
|
||||
}
|
||||
|
||||
let request = request_builder.build()?;
|
||||
@@ -159,33 +179,28 @@ impl ControllerTrait for Controller {
|
||||
model = format!("{:?}", request.model),
|
||||
?messages_count,
|
||||
request = request_as_json,
|
||||
"Sending OpenAI chat completion API request"
|
||||
"Sending OpenAI response API request"
|
||||
);
|
||||
}
|
||||
|
||||
let response = self.client.chat().create(request).await?;
|
||||
let response = self.client.responses().create(request).await?;
|
||||
|
||||
tracing::trace!(
|
||||
?response,
|
||||
"Got response from the OpenAI chat completion API"
|
||||
);
|
||||
tracing::trace!(?response, "Got response from the OpenAI response API");
|
||||
|
||||
// We only request 1 result, so there should only be 1 choice.
|
||||
if let Some(choice) = response.choices.into_iter().next() {
|
||||
match choice.message.content {
|
||||
Some(text) => {
|
||||
return Ok(TextGenerationResult { text });
|
||||
}
|
||||
None => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"No content was found in the response choice from the OpenAI chat completion API"
|
||||
));
|
||||
for item in response.output {
|
||||
if let OutputItem::Message(message) = item {
|
||||
for content in message.content {
|
||||
if let OutputMessageContent::OutputText(text_content) = content {
|
||||
return Ok(TextGenerationResult {
|
||||
text: text_content.text,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow::anyhow!(
|
||||
"No response messages choices were returned from the OpenAI chat completion API"
|
||||
"No response messages choices were returned from the OpenAI response API"
|
||||
))
|
||||
}
|
||||
|
||||
@@ -209,11 +224,7 @@ impl ControllerTrait for Controller {
|
||||
|
||||
let request = CreateTranscriptionRequestArgs::default()
|
||||
.model(&speech_to_text_config.model_id)
|
||||
.file(async_openai::types::AudioInput::from_vec_u8(
|
||||
filename,
|
||||
media,
|
||||
mime_type.to_string(),
|
||||
))
|
||||
.file(AudioInput::from_vec_u8(filename, media))
|
||||
.language(language.clone())
|
||||
.build()?;
|
||||
|
||||
@@ -223,7 +234,7 @@ impl ControllerTrait for Controller {
|
||||
"Sending OpenAI speech-to-text API request"
|
||||
);
|
||||
|
||||
let response = self.client.audio().transcribe(request).await?;
|
||||
let response = self.client.audio().transcription().create(request).await?;
|
||||
|
||||
tracing::trace!(
|
||||
?response,
|
||||
@@ -255,11 +266,12 @@ impl ControllerTrait for Controller {
|
||||
let model = if params.cheaper_model_switching_allowed {
|
||||
// Switch to a cheaper model
|
||||
match original_model {
|
||||
async_openai::types::ImageModel::DallE2 => async_openai::types::ImageModel::DallE2,
|
||||
async_openai::types::ImageModel::DallE3 => async_openai::types::ImageModel::DallE2,
|
||||
async_openai::types::ImageModel::Other(_) => {
|
||||
async_openai::types::ImageModel::DallE2
|
||||
}
|
||||
ImageModel::DallE2 => ImageModel::DallE2,
|
||||
ImageModel::DallE3 => ImageModel::DallE2,
|
||||
ImageModel::GptImage1 => ImageModel::GptImage1Mini,
|
||||
ImageModel::GptImage1dot5 => ImageModel::GptImage1Mini,
|
||||
ImageModel::GptImage1Mini => ImageModel::GptImage1Mini,
|
||||
ImageModel::Other(_) => ImageModel::DallE2,
|
||||
}
|
||||
} else {
|
||||
original_model
|
||||
@@ -269,11 +281,24 @@ impl ControllerTrait for Controller {
|
||||
// Switch to a cheaper quality
|
||||
match &image_generation_config.quality {
|
||||
Some(quality) => match quality {
|
||||
async_openai::types::ImageQuality::Standard => {
|
||||
Some(async_openai::types::ImageQuality::Standard)
|
||||
async_openai::types::images::ImageQuality::Standard => {
|
||||
Some(async_openai::types::images::ImageQuality::Standard)
|
||||
}
|
||||
async_openai::types::ImageQuality::HD => {
|
||||
Some(async_openai::types::ImageQuality::Standard)
|
||||
async_openai::types::images::ImageQuality::HD => {
|
||||
Some(async_openai::types::images::ImageQuality::Standard)
|
||||
}
|
||||
// New quality levels - keep as-is or downgrade to Standard
|
||||
async_openai::types::images::ImageQuality::High => {
|
||||
Some(async_openai::types::images::ImageQuality::Standard)
|
||||
}
|
||||
async_openai::types::images::ImageQuality::Medium => {
|
||||
Some(async_openai::types::images::ImageQuality::Medium)
|
||||
}
|
||||
async_openai::types::images::ImageQuality::Low => {
|
||||
Some(async_openai::types::images::ImageQuality::Low)
|
||||
}
|
||||
async_openai::types::images::ImageQuality::Auto => {
|
||||
Some(async_openai::types::images::ImageQuality::Auto)
|
||||
}
|
||||
},
|
||||
None => None,
|
||||
@@ -282,20 +307,21 @@ impl ControllerTrait for Controller {
|
||||
image_generation_config.quality.clone()
|
||||
};
|
||||
|
||||
let size = params
|
||||
.size_override
|
||||
.map(|s| convert_string_to_enum::<async_openai::types::ImageSize>(&s).unwrap())
|
||||
.or(image_generation_config.size);
|
||||
let size = if params.smallest_size_possible {
|
||||
Some(get_sticker_size(&model))
|
||||
} else {
|
||||
image_generation_config.size
|
||||
};
|
||||
|
||||
let response_format = match model.clone() {
|
||||
ImageModel::DallE2 => Some(ImageResponseFormat::B64Json),
|
||||
ImageModel::DallE3 => Some(ImageResponseFormat::B64Json),
|
||||
ImageModel::Other(model_str) => match model_str.as_str() {
|
||||
// gpt-image-1 only outputs base64 and we don't need to specify the response format.
|
||||
// In fact, specifying the response format results in an error.
|
||||
OPENAI_IMAGE_MODEL_GPT_IMAGE_1 => None,
|
||||
_ => Some(ImageResponseFormat::B64Json),
|
||||
},
|
||||
// gpt-image-1 only outputs base64 and we don't need to specify the response format.
|
||||
// In fact, specifying the response format results in an error.
|
||||
ImageModel::GptImage1 => None,
|
||||
ImageModel::GptImage1Mini => None,
|
||||
ImageModel::GptImage1dot5 => None,
|
||||
ImageModel::Other(_) => Some(ImageResponseFormat::B64Json),
|
||||
};
|
||||
|
||||
let mut request_builder = CreateImageRequestArgs::default();
|
||||
@@ -329,15 +355,15 @@ impl ControllerTrait for Controller {
|
||||
"Sending OpenAI image generation API request"
|
||||
);
|
||||
|
||||
let response = self.client.images().create(request).await?;
|
||||
let response = self.client.images().generate(request).await?;
|
||||
|
||||
if let Some(image) = response.data.into_iter().next() {
|
||||
match image.deref() {
|
||||
async_openai::types::Image::B64Json {
|
||||
Image::B64Json {
|
||||
b64_json,
|
||||
revised_prompt,
|
||||
} => {
|
||||
let bytes = base64_decode(b64_json)?;
|
||||
let bytes = base64_decode(b64_json.as_ref())?;
|
||||
|
||||
return Ok(ImageGenerationResult {
|
||||
bytes,
|
||||
@@ -374,15 +400,21 @@ impl ControllerTrait for Controller {
|
||||
return Err(anyhow::anyhow!("No image sources provided"));
|
||||
}
|
||||
|
||||
let mut image_inputs = Vec::new();
|
||||
let mut image_inputs: Vec<ImageInput> = Vec::new();
|
||||
for image in images {
|
||||
image_inputs.push(image.into());
|
||||
}
|
||||
|
||||
let dalle2_size = match image_generation_config.size {
|
||||
Some(async_openai::types::ImageSize::S256x256) => Some(DallE2ImageSize::S256x256),
|
||||
Some(async_openai::types::ImageSize::S512x512) => Some(DallE2ImageSize::S512x512),
|
||||
Some(async_openai::types::ImageSize::S1024x1024) => Some(DallE2ImageSize::S1024x1024),
|
||||
Some(async_openai::types::images::ImageSize::S256x256) => {
|
||||
Some(async_openai::types::images::ImageSize::S256x256)
|
||||
}
|
||||
Some(async_openai::types::images::ImageSize::S512x512) => {
|
||||
Some(async_openai::types::images::ImageSize::S512x512)
|
||||
}
|
||||
Some(async_openai::types::images::ImageSize::S1024x1024) => {
|
||||
Some(async_openai::types::images::ImageSize::S1024x1024)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
@@ -391,16 +423,14 @@ impl ControllerTrait for Controller {
|
||||
.map_err(|err| anyhow::anyhow!(err))?;
|
||||
|
||||
let response_format = match model.clone() {
|
||||
async_openai::types::ImageModel::DallE2 => {
|
||||
Some(async_openai::types::ImageResponseFormat::B64Json)
|
||||
}
|
||||
async_openai::types::ImageModel::DallE3 => {
|
||||
Some(async_openai::types::ImageResponseFormat::B64Json)
|
||||
}
|
||||
async_openai::types::ImageModel::Other(model_str) => match model_str.as_str() {
|
||||
OPENAI_IMAGE_MODEL_GPT_IMAGE_1 => None,
|
||||
_ => Some(async_openai::types::ImageResponseFormat::B64Json),
|
||||
},
|
||||
ImageModel::DallE2 => Some(ImageResponseFormat::B64Json),
|
||||
ImageModel::DallE3 => Some(ImageResponseFormat::B64Json),
|
||||
// gpt-image-1 only outputs base64 and we don't need to specify the response format.
|
||||
// In fact, specifying the response format results in an error.
|
||||
ImageModel::GptImage1 => None,
|
||||
ImageModel::GptImage1Mini => None,
|
||||
ImageModel::GptImage1dot5 => None,
|
||||
ImageModel::Other(_) => Some(ImageResponseFormat::B64Json),
|
||||
};
|
||||
|
||||
let mut request_builder = CreateImageEditRequestArgs::default();
|
||||
@@ -429,12 +459,12 @@ impl ControllerTrait for Controller {
|
||||
"Sending OpenAI image edit API request"
|
||||
);
|
||||
|
||||
let response = self.client.images().create_edit(request).await?;
|
||||
let response = self.client.images().edit(request).await?;
|
||||
|
||||
if let Some(image_data) = response.data.into_iter().next() {
|
||||
match image_data.deref() {
|
||||
Image::B64Json { b64_json, .. } => {
|
||||
let bytes = base64_decode(b64_json)?;
|
||||
let bytes = base64_decode(b64_json.as_ref())?;
|
||||
return Ok(ImageEditResult {
|
||||
bytes,
|
||||
mime_type: mxlink::mime::IMAGE_PNG,
|
||||
@@ -471,7 +501,7 @@ impl ControllerTrait for Controller {
|
||||
|
||||
let voice = if let Some(voice_string) = params.voice_override {
|
||||
// This is a hacky way to construct a Voice enum from the string we have.
|
||||
let voice: serde_json::Result<async_openai::types::Voice> =
|
||||
let voice: serde_json::Result<async_openai::types::audio::Voice> =
|
||||
serde_json::from_str(&format!("\"{}\"", voice_string));
|
||||
match voice {
|
||||
Ok(voice) => voice,
|
||||
@@ -511,7 +541,7 @@ impl ControllerTrait for Controller {
|
||||
"Sending OpenAI text-to-speech API request"
|
||||
);
|
||||
|
||||
let result = self.client.audio().speech(request).await?;
|
||||
let result = self.client.audio().speech().create(request).await?;
|
||||
|
||||
Ok(TextToSpeechResult {
|
||||
bytes: result.bytes.into(),
|
||||
@@ -570,15 +600,15 @@ impl ControllerTrait for Controller {
|
||||
}
|
||||
|
||||
fn response_format_to_mime_type(
|
||||
response_format: &async_openai::types::SpeechResponseFormat,
|
||||
response_format: &async_openai::types::audio::SpeechResponseFormat,
|
||||
) -> Option<mxlink::mime::Mime> {
|
||||
let content_type = match response_format {
|
||||
async_openai::types::SpeechResponseFormat::Mp3 => "audio/mp3".to_owned(),
|
||||
async_openai::types::SpeechResponseFormat::Wav => "audio/wav".to_owned(),
|
||||
async_openai::types::SpeechResponseFormat::Opus => "audio/ogg".to_owned(),
|
||||
async_openai::types::SpeechResponseFormat::Aac => "audio/aac".to_owned(),
|
||||
async_openai::types::SpeechResponseFormat::Flac => "audio/flac".to_owned(),
|
||||
async_openai::types::SpeechResponseFormat::Pcm => "audio/L8".to_owned(),
|
||||
async_openai::types::audio::SpeechResponseFormat::Mp3 => "audio/mp3".to_owned(),
|
||||
async_openai::types::audio::SpeechResponseFormat::Wav => "audio/wav".to_owned(),
|
||||
async_openai::types::audio::SpeechResponseFormat::Opus => "audio/ogg".to_owned(),
|
||||
async_openai::types::audio::SpeechResponseFormat::Aac => "audio/aac".to_owned(),
|
||||
async_openai::types::audio::SpeechResponseFormat::Flac => "audio/flac".to_owned(),
|
||||
async_openai::types::audio::SpeechResponseFormat::Pcm => "audio/L8".to_owned(),
|
||||
};
|
||||
|
||||
match content_type.parse() {
|
||||
@@ -606,3 +636,17 @@ fn audio_mime_type_to_file_name(mime_type: &mxlink::mime::Mime) -> Option<String
|
||||
|
||||
Some(format!("audio.{}", file_extension))
|
||||
}
|
||||
|
||||
/// Returns the smallest supported size for stickers based on what the image model supports.
|
||||
fn get_sticker_size(model: &ImageModel) -> async_openai::types::images::ImageSize {
|
||||
use async_openai::types::images::ImageSize;
|
||||
|
||||
match model {
|
||||
ImageModel::DallE2 => ImageSize::S256x256,
|
||||
ImageModel::DallE3 => ImageSize::S1024x1024,
|
||||
ImageModel::GptImage1 => ImageSize::S1024x1024,
|
||||
ImageModel::GptImage1Mini => ImageSize::S1024x1024,
|
||||
ImageModel::GptImage1dot5 => ImageSize::S1024x1024,
|
||||
ImageModel::Other(_) => ImageSize::S1024x1024,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,16 +16,16 @@ use super::super::AgentInstantiationResult;
|
||||
use super::ConfigTrait;
|
||||
use super::controller::ControllerType;
|
||||
|
||||
pub const OPENAI_IMAGE_MODEL_GPT_IMAGE_1: &str = "gpt-image-1";
|
||||
pub const OPENAI_IMAGE_MODEL_GPT_IMAGE_1_DOT_5: &str = "gpt-image-1.5";
|
||||
|
||||
pub fn create_controller_from_yaml_value_config(
|
||||
agent_id: &str,
|
||||
config: serde_yaml::Value,
|
||||
config: serde_yaml_ng::Value,
|
||||
) -> AgentInstantiationResult<ControllerType> {
|
||||
let config = match &config {
|
||||
serde_yaml::Value::Mapping(_) => {
|
||||
serde_yaml_ng::Value::Mapping(_) => {
|
||||
let config: Config =
|
||||
serde_yaml::from_value(config).map_err(AgentInstantiationError::Yaml)?;
|
||||
serde_yaml_ng::from_value(config).map_err(AgentInstantiationError::Yaml)?;
|
||||
|
||||
config
|
||||
.validate()
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
use async_openai::types::{
|
||||
ChatCompletionRequestAssistantMessageArgs, ChatCompletionRequestMessage,
|
||||
ChatCompletionRequestMessageContentPartImage, ChatCompletionRequestSystemMessageArgs,
|
||||
ChatCompletionRequestUserMessageArgs, ChatCompletionRequestUserMessageContent,
|
||||
ChatCompletionRequestUserMessageContentPart, ImageUrlArgs,
|
||||
use async_openai::types::responses::{
|
||||
EasyInputContent, EasyInputMessage, ImageDetail, InputContent, InputFileArgs,
|
||||
InputImageContent, InputItem, InputParam, MessageType, Role,
|
||||
};
|
||||
|
||||
use crate::conversation::llm::{
|
||||
@@ -10,93 +8,57 @@ use crate::conversation::llm::{
|
||||
};
|
||||
use crate::utils::base64::base64_encode;
|
||||
|
||||
pub fn convert_llm_messages_to_openai_messages(
|
||||
pub fn convert_llm_messages_to_openai_response_input(
|
||||
conversation_messages: Vec<LLMMessage>,
|
||||
) -> Vec<ChatCompletionRequestMessage> {
|
||||
let mut openai_conversation_messages: Vec<ChatCompletionRequestMessage> =
|
||||
Vec::with_capacity(conversation_messages.len());
|
||||
) -> InputParam {
|
||||
let mut items = Vec::with_capacity(conversation_messages.len());
|
||||
|
||||
for message in conversation_messages {
|
||||
let openai_message = convert_llm_message_to_openai_message(message);
|
||||
if let Some(openai_message) = openai_message {
|
||||
openai_conversation_messages.push(openai_message);
|
||||
}
|
||||
}
|
||||
let role = match message.author {
|
||||
LLMAuthor::Prompt => Role::System,
|
||||
LLMAuthor::Assistant => Role::Assistant,
|
||||
LLMAuthor::User => Role::User,
|
||||
};
|
||||
|
||||
openai_conversation_messages
|
||||
}
|
||||
let content = match message.content {
|
||||
LLMMessageContent::Text(text) => EasyInputContent::Text(text),
|
||||
LLMMessageContent::Image(image_details) => {
|
||||
let image_url = format!(
|
||||
"data:{};base64,{}",
|
||||
image_details.mime,
|
||||
base64_encode(&image_details.data)
|
||||
);
|
||||
|
||||
fn convert_llm_message_to_openai_message(
|
||||
llm_message: LLMMessage,
|
||||
) -> Option<ChatCompletionRequestMessage> {
|
||||
match &llm_message.content {
|
||||
LLMMessageContent::Text(text) => Some(match llm_message.author {
|
||||
LLMAuthor::Prompt => ChatCompletionRequestSystemMessageArgs::default()
|
||||
.content(text.clone())
|
||||
.build()
|
||||
.expect("Failed building OpenAI system message")
|
||||
.into(),
|
||||
LLMAuthor::Assistant => ChatCompletionRequestAssistantMessageArgs::default()
|
||||
.content(text.clone())
|
||||
.build()
|
||||
.expect("Failed building OpenAI assistant message")
|
||||
.into(),
|
||||
LLMAuthor::User => ChatCompletionRequestUserMessageArgs::default()
|
||||
.content(text.clone())
|
||||
.build()
|
||||
.expect("Failed building OpenAI user message")
|
||||
.into(),
|
||||
}),
|
||||
LLMMessageContent::Image(image_details) => {
|
||||
let image_url = format!(
|
||||
"data:{};base64,{}",
|
||||
image_details.mime,
|
||||
base64_encode(&image_details.data)
|
||||
);
|
||||
|
||||
let part = ChatCompletionRequestUserMessageContentPart::ImageUrl(
|
||||
ChatCompletionRequestMessageContentPartImage {
|
||||
image_url: ImageUrlArgs::default()
|
||||
.url(image_url)
|
||||
.build()
|
||||
.expect("Failed building OpenAI image url"),
|
||||
},
|
||||
);
|
||||
|
||||
let message_content = ChatCompletionRequestUserMessageContent::Array(vec![part]);
|
||||
|
||||
match llm_message.author {
|
||||
LLMAuthor::User => Some(
|
||||
ChatCompletionRequestUserMessageArgs::default()
|
||||
.content(message_content)
|
||||
.build()
|
||||
.expect("Failed building OpenAI user message")
|
||||
.into(),
|
||||
),
|
||||
_ => {
|
||||
tracing::warn!(
|
||||
"OpenAI API does not support image content for messages authored by {:?}. This message part will be skipped.",
|
||||
llm_message.author
|
||||
);
|
||||
None
|
||||
}
|
||||
EasyInputContent::ContentList(vec![InputContent::InputImage(InputImageContent {
|
||||
image_url: Some(image_url),
|
||||
detail: ImageDetail::Auto,
|
||||
file_id: None,
|
||||
})])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
LLMMessageContent::File(file_details) => {
|
||||
let file_data = format!(
|
||||
"data:{};base64,{}",
|
||||
file_details.mime,
|
||||
base64_encode(&file_details.data)
|
||||
);
|
||||
|
||||
pub(super) fn convert_string_to_enum<T>(value: &str) -> Result<T, String>
|
||||
where
|
||||
T: serde::de::DeserializeOwned,
|
||||
{
|
||||
// This is a hacky way to construct an enum from the string we have.
|
||||
let enum_result: serde_json::Result<T> = serde_json::from_str(&format!("\"{}\"", value));
|
||||
match enum_result {
|
||||
Ok(enum_result) => Ok(enum_result),
|
||||
Err(err) => {
|
||||
tracing::debug!(?err, "Failed to parse into enum");
|
||||
let file_content = InputFileArgs::default()
|
||||
.file_data(file_data)
|
||||
.filename(file_details.filename())
|
||||
.build()
|
||||
.expect("Failed to build InputFileContent");
|
||||
|
||||
Err(format!("The value ({}) is not supported.", value))
|
||||
}
|
||||
EasyInputContent::ContentList(vec![InputContent::InputFile(file_content)])
|
||||
}
|
||||
};
|
||||
|
||||
items.push(InputItem::EasyMessage(EasyInputMessage {
|
||||
r#type: MessageType::Message,
|
||||
role,
|
||||
content,
|
||||
phase: None,
|
||||
}));
|
||||
}
|
||||
|
||||
InputParam::Items(items)
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ impl TryInto<OpenAITextGenerationConfig> for TextGenerationConfig {
|
||||
max_response_tokens: self.max_response_tokens,
|
||||
max_completion_tokens: None,
|
||||
max_context_tokens: self.max_context_tokens,
|
||||
tools: Default::default(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -161,13 +162,14 @@ impl TryInto<OpenAITextToSpeechConfig> for TextToSpeechConfig {
|
||||
type Error = String;
|
||||
|
||||
fn try_into(self) -> Result<OpenAITextToSpeechConfig, Self::Error> {
|
||||
let model_id = convert_string_to_enum::<async_openai::types::SpeechModel>(&self.model_id)?;
|
||||
let model_id =
|
||||
convert_string_to_enum::<async_openai::types::audio::SpeechModel>(&self.model_id)?;
|
||||
|
||||
let voice = convert_string_to_enum::<async_openai::types::Voice>(&self.voice)?;
|
||||
let voice = convert_string_to_enum::<async_openai::types::audio::Voice>(&self.voice)?;
|
||||
|
||||
let response_format = convert_string_to_enum::<async_openai::types::SpeechResponseFormat>(
|
||||
&self.response_format,
|
||||
)?;
|
||||
let response_format = convert_string_to_enum::<
|
||||
async_openai::types::audio::SpeechResponseFormat,
|
||||
>(&self.response_format)?;
|
||||
|
||||
Ok(OpenAITextToSpeechConfig {
|
||||
model_id,
|
||||
@@ -224,25 +226,25 @@ impl TryInto<OpenAIImageGenerationConfig> for ImageGenerationConfig {
|
||||
|
||||
fn try_into(self) -> Result<OpenAIImageGenerationConfig, Self::Error> {
|
||||
let size = if let Some(size) = &self.size {
|
||||
Some(convert_string_to_enum::<async_openai::types::ImageSize>(
|
||||
size,
|
||||
)?)
|
||||
Some(convert_string_to_enum::<
|
||||
async_openai::types::images::ImageSize,
|
||||
>(size)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let style = if let Some(style) = &self.style {
|
||||
Some(convert_string_to_enum::<async_openai::types::ImageStyle>(
|
||||
style,
|
||||
)?)
|
||||
Some(convert_string_to_enum::<
|
||||
async_openai::types::images::ImageStyle,
|
||||
>(style)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let quality = if let Some(quality) = &self.quality {
|
||||
Some(convert_string_to_enum::<async_openai::types::ImageQuality>(
|
||||
quality,
|
||||
)?)
|
||||
Some(convert_string_to_enum::<
|
||||
async_openai::types::images::ImageQuality,
|
||||
>(quality)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
@@ -3,6 +3,8 @@ use etke_openai_api_rust::chat::{ChatApi, ChatBody};
|
||||
use etke_openai_api_rust::images::{ImagesApi, ImagesBody};
|
||||
use etke_openai_api_rust::{Auth, Message, OpenAI};
|
||||
|
||||
const SMALLEST_IMAGE_SIZE: &str = "256x256";
|
||||
|
||||
use super::super::ControllerTrait;
|
||||
use crate::utils::base64::base64_decode;
|
||||
use crate::{
|
||||
@@ -62,6 +64,7 @@ impl ControllerTrait for Controller {
|
||||
|
||||
let messages = vec![LLMMessage {
|
||||
author: LLMAuthor::User,
|
||||
sender_id: None,
|
||||
content: LLMMessageContent::Text("Hello!".to_string()),
|
||||
timestamp: chrono::Utc::now(),
|
||||
}];
|
||||
@@ -99,6 +102,7 @@ impl ControllerTrait for Controller {
|
||||
} else {
|
||||
Some(LLMMessage {
|
||||
author: LLMAuthor::Prompt,
|
||||
sender_id: None,
|
||||
content: LLMMessageContent::Text(prompt_text),
|
||||
timestamp: chrono::Utc::now(),
|
||||
})
|
||||
@@ -303,9 +307,11 @@ impl ControllerTrait for Controller {
|
||||
// when they span multiple lines.
|
||||
let prompt = prompt.replace("\n", " ");
|
||||
|
||||
let size: Option<String> = params
|
||||
.size_override
|
||||
.or_else(|| image_generation_config.size.clone());
|
||||
let size: Option<String> = if params.smallest_size_possible {
|
||||
Some(SMALLEST_IMAGE_SIZE.to_owned())
|
||||
} else {
|
||||
image_generation_config.size.clone()
|
||||
};
|
||||
|
||||
let request = ImagesBody {
|
||||
model: Some(image_generation_config.model_id.to_owned()),
|
||||
|
||||
@@ -26,12 +26,12 @@ use super::controller::ControllerType;
|
||||
|
||||
pub fn create_controller_from_yaml_value_config(
|
||||
agent_id: &str,
|
||||
config: serde_yaml::Value,
|
||||
config: serde_yaml_ng::Value,
|
||||
) -> AgentInstantiationResult<ControllerType> {
|
||||
let config = match &config {
|
||||
serde_yaml::Value::Mapping(_) => {
|
||||
serde_yaml_ng::Value::Mapping(_) => {
|
||||
let config: Config =
|
||||
serde_yaml::from_value(config).map_err(AgentInstantiationError::Yaml)?;
|
||||
serde_yaml_ng::from_value(config).map_err(AgentInstantiationError::Yaml)?;
|
||||
|
||||
config
|
||||
.validate()
|
||||
|
||||
@@ -40,6 +40,12 @@ fn convert_llm_message_to_openai_message(llm_message: LLMMessage) -> Option<Mess
|
||||
);
|
||||
None
|
||||
}
|
||||
LLMMessageContent::File(_file_details) => {
|
||||
tracing::warn!(
|
||||
"The OpenAI-compat provider's library does not support file content. This file message will be skipped."
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
use std::fs;
|
||||
use std::sync::Arc;
|
||||
use std::{future::Future, pin::Pin};
|
||||
|
||||
use mxlink::matrix_sdk::Room;
|
||||
use mxlink::matrix_sdk::media::{MediaFormat, MediaRequestParameters};
|
||||
use mxlink::matrix_sdk::ruma::api::client::profile::{AvatarUrl, DisplayName};
|
||||
use mxlink::matrix_sdk::ruma::{
|
||||
MilliSecondsSinceUnixEpoch, OwnedUserId, events::room::MediaSource,
|
||||
};
|
||||
use mxlink::matrix_sdk::ruma::api::client::profile::{AvatarUrl, DisplayName};
|
||||
|
||||
use mxlink::{
|
||||
InitConfig, LoginConfig, LoginCredentials, LoginEncryption, MatrixLink, PersistenceConfig,
|
||||
@@ -18,12 +19,13 @@ use mxlink::helpers::account_data_config::{
|
||||
RoomConfigManager as AccountDataRoomConfigManager,
|
||||
};
|
||||
use mxlink::helpers::encryption::Manager as EncryptionManager;
|
||||
use mxlink::mime::Mime;
|
||||
|
||||
use crate::agent::Manager as AgentManager;
|
||||
use crate::entity::catch_up_marker::{
|
||||
CatchUpMarker, CatchUpMarkerManager, DelayedCatchUpMarkerManager,
|
||||
};
|
||||
use crate::entity::cfg::Config;
|
||||
use crate::entity::cfg::{Avatar, Config, ConfigUserAuth};
|
||||
use crate::entity::globalconfig::{GlobalConfig, GlobalConfigurationManager};
|
||||
use crate::entity::roomconfig::{RoomConfig, RoomConfigurationManager};
|
||||
|
||||
@@ -316,34 +318,72 @@ impl Bot {
|
||||
}
|
||||
}
|
||||
|
||||
let should_update_avatar = match ¤t_avatar_url {
|
||||
Some(avatar_url) => {
|
||||
let request = MediaRequestParameters {
|
||||
source: MediaSource::Plain(avatar_url.to_owned()),
|
||||
format: MediaFormat::File,
|
||||
};
|
||||
|
||||
let content = media
|
||||
.get_media_content(&request, true)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed fetching existing avatar: {:?}", e))?;
|
||||
|
||||
content.as_slice() != LOGO_BYTES
|
||||
let desired_avatar: Option<(Vec<u8>, Mime)> = match &self.inner.config.user.avatar {
|
||||
Avatar::Keep => {
|
||||
tracing::info!("Avatar configured to keep current, skipping avatar management");
|
||||
None
|
||||
}
|
||||
Avatar::Default => {
|
||||
tracing::info!("Avatar configured to use default");
|
||||
Some((
|
||||
LOGO_BYTES.to_vec(),
|
||||
LOGO_MIME_TYPE
|
||||
.parse()
|
||||
.expect("Failed parsing mime type for logo"),
|
||||
))
|
||||
}
|
||||
Avatar::Custom(avatar_path) => {
|
||||
tracing::info!(?avatar_path, "Avatar configured to use custom path");
|
||||
let bytes = fs::read(avatar_path).map_err(|e| {
|
||||
anyhow::anyhow!("Failed reading avatar from {:?}: {:?}", avatar_path, e)
|
||||
})?;
|
||||
let mime = mime_guess::from_path(avatar_path).first_or_octet_stream();
|
||||
tracing::debug!(?mime, bytes_len = bytes.len(), "Loaded custom avatar");
|
||||
Some((bytes, mime))
|
||||
}
|
||||
None => true,
|
||||
};
|
||||
|
||||
if should_update_avatar {
|
||||
tracing::info!("Updating avatar..");
|
||||
if let Some((desired_bytes, mime_type)) = desired_avatar {
|
||||
let should_update_avatar = match ¤t_avatar_url {
|
||||
Some(avatar_url) => {
|
||||
tracing::debug!(?avatar_url, "Fetching current avatar to compare");
|
||||
let request = MediaRequestParameters {
|
||||
source: MediaSource::Plain(avatar_url.to_owned()),
|
||||
format: MediaFormat::File,
|
||||
};
|
||||
|
||||
let mime_type = LOGO_MIME_TYPE
|
||||
.parse()
|
||||
.expect("Failed parsing mime type for logo");
|
||||
let content = media
|
||||
.get_media_content(&request, true)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed fetching existing avatar: {:?}", e))?;
|
||||
|
||||
account
|
||||
.upload_avatar(&mime_type, LOGO_BYTES.to_vec())
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed uploading avatar: {:?}", e))?;
|
||||
let needs_update = content.as_slice() != desired_bytes;
|
||||
|
||||
tracing::debug!(
|
||||
current_bytes_len = content.len(),
|
||||
desired_bytes_len = desired_bytes.len(),
|
||||
?needs_update,
|
||||
"Compared current and desired avatar"
|
||||
);
|
||||
|
||||
needs_update
|
||||
}
|
||||
None => {
|
||||
tracing::debug!("No current avatar set, will upload");
|
||||
true
|
||||
}
|
||||
};
|
||||
|
||||
if should_update_avatar {
|
||||
tracing::info!("Updating avatar..");
|
||||
account
|
||||
.upload_avatar(&mime_type, desired_bytes)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed uploading avatar: {:?}", e))?;
|
||||
tracing::info!("Avatar updated successfully");
|
||||
} else {
|
||||
tracing::debug!("Avatar already up to date, skipping upload");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -355,10 +395,22 @@ async fn create_matrix_link(config: &Config) -> anyhow::Result<MatrixLink> {
|
||||
let session_encryption_key = config.persistence.session_encryption_key()?;
|
||||
let db_dir_path: std::path::PathBuf = config.persistence.db_dir_path()?;
|
||||
|
||||
let login_creds = LoginCredentials::UserPassword(
|
||||
config.user.mxid_localpart.to_owned(),
|
||||
config.user.password.to_owned(),
|
||||
);
|
||||
let user_auth = config.user.auth_config(&config.homeserver.server_name)?;
|
||||
|
||||
let login_creds = match user_auth {
|
||||
ConfigUserAuth::UserPassword { username, password } => {
|
||||
LoginCredentials::UserPassword(username, password)
|
||||
}
|
||||
ConfigUserAuth::AccessToken {
|
||||
user_id,
|
||||
device_id,
|
||||
access_token,
|
||||
} => LoginCredentials::AccessToken {
|
||||
user_id,
|
||||
device_id,
|
||||
access_token,
|
||||
},
|
||||
};
|
||||
|
||||
let login_encryption = LoginEncryption::new(
|
||||
config.user.encryption.recovery_passphrase.clone(),
|
||||
|
||||
@@ -5,7 +5,7 @@ use anyhow::anyhow;
|
||||
|
||||
use crate::agent::AgentPurpose;
|
||||
|
||||
pub use crate::entity::cfg::{Config, defaults as cfg_defaults, env as cfg_env};
|
||||
pub use crate::entity::cfg::{Avatar, Config, defaults as cfg_defaults, env as cfg_env};
|
||||
|
||||
pub fn load() -> anyhow::Result<Config> {
|
||||
let config_file_path = env::var(cfg_env::BAIBOT_CONFIG_FILE_PATH)
|
||||
@@ -21,7 +21,7 @@ pub fn load() -> anyhow::Result<Config> {
|
||||
}
|
||||
|
||||
let config_str = std::fs::read_to_string(config_file_path)?;
|
||||
let mut config: Config = serde_yaml::from_str(&config_str)?;
|
||||
let mut config: Config = serde_yaml_ng::from_str(&config_str)?;
|
||||
|
||||
// Allow environment variables to override some configuration keys
|
||||
for (key, value) in env::vars() {
|
||||
@@ -29,11 +29,25 @@ pub fn load() -> anyhow::Result<Config> {
|
||||
cfg_env::BAIBOT_HOMESERVER_SERVER_NAME => config.homeserver.server_name = value,
|
||||
cfg_env::BAIBOT_HOMESERVER_URL => config.homeserver.url = value,
|
||||
cfg_env::BAIBOT_USER_MXID_LOCALPART => config.user.mxid_localpart = value,
|
||||
cfg_env::BAIBOT_USER_PASSWORD => config.user.password = value,
|
||||
cfg_env::BAIBOT_USER_PASSWORD => {
|
||||
config.user.password = optional_non_empty(value);
|
||||
}
|
||||
cfg_env::BAIBOT_USER_ACCESS_TOKEN => {
|
||||
config.user.access_token = optional_non_empty(value);
|
||||
}
|
||||
cfg_env::BAIBOT_USER_DEVICE_ID => {
|
||||
config.user.device_id = optional_non_empty(value);
|
||||
}
|
||||
cfg_env::BAIBOT_USER_ENCRYPTION_RECOVERY_PASSPHRASE => {
|
||||
config.user.encryption.recovery_passphrase = Some(value);
|
||||
}
|
||||
cfg_env::BAIBOT_USER_ENCRYPTION_RECOVERY_RESET_ALLOWED => {
|
||||
config.user.encryption.recovery_reset_allowed = value.parse::<bool>()?;
|
||||
}
|
||||
cfg_env::BAIBOT_USER_NAME => config.user.name = value,
|
||||
cfg_env::BAIBOT_USER_AVATAR => {
|
||||
config.user.avatar = Avatar::from_string(value);
|
||||
}
|
||||
cfg_env::BAIBOT_COMMAND_PREFIX => config.command_prefix = value,
|
||||
cfg_env::BAIBOT_ROOM_POST_JOIN_SELF_INTRODUCTION_ENABLED => {
|
||||
config.room.post_join_self_introduction_enabled = value.parse::<bool>()?;
|
||||
@@ -51,6 +65,9 @@ pub fn load() -> anyhow::Result<Config> {
|
||||
cfg_env::BAIBOT_PERSISTENCE_DATA_DIR_PATH => {
|
||||
config.persistence.data_dir_path = Some(value);
|
||||
}
|
||||
cfg_env::BAIBOT_PERSISTENCE_SESSION_ENCRYPTION_KEY => {
|
||||
config.persistence.session_encryption_key = Some(value);
|
||||
}
|
||||
cfg_env::BAIBOT_PERSISTENCE_CONFIG_ENCRYPTION_KEY => {
|
||||
config.persistence.config_encryption_key = Some(value);
|
||||
}
|
||||
@@ -111,3 +128,7 @@ pub fn load() -> anyhow::Result<Config> {
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn optional_non_empty(value: String) -> Option<String> {
|
||||
if value.is_empty() { None } else { Some(value) }
|
||||
}
|
||||
|
||||
@@ -28,18 +28,18 @@ pub async fn handle_set(
|
||||
message_context: &MessageContext,
|
||||
patterns: &Option<Vec<String>>,
|
||||
) -> anyhow::Result<()> {
|
||||
if let Some(patterns) = patterns {
|
||||
if let Err(err) = mxidwc::parse_patterns_vector(patterns) {
|
||||
bot.messaging()
|
||||
.send_error_markdown_no_fail(
|
||||
message_context.room(),
|
||||
&strings::access::failed_to_parse_patterns(&err.to_string()),
|
||||
MessageResponseType::Reply(message_context.thread_info().root_event_id.clone()),
|
||||
)
|
||||
.await;
|
||||
if let Some(patterns) = patterns
|
||||
&& let Err(err) = mxidwc::parse_patterns_vector(patterns)
|
||||
{
|
||||
bot.messaging()
|
||||
.send_error_markdown_no_fail(
|
||||
message_context.room(),
|
||||
&strings::access::failed_to_parse_patterns(&err.to_string()),
|
||||
MessageResponseType::Reply(message_context.thread_info().root_event_id.clone()),
|
||||
)
|
||||
.await;
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut global_config_manager_guard = bot.global_config_manager().lock().await;
|
||||
|
||||
@@ -24,18 +24,18 @@ pub async fn handle_set(
|
||||
message_context: &MessageContext,
|
||||
patterns: &Option<Vec<String>>,
|
||||
) -> anyhow::Result<()> {
|
||||
if let Some(patterns) = patterns {
|
||||
if let Err(err) = mxidwc::parse_patterns_vector(patterns) {
|
||||
bot.messaging()
|
||||
.send_error_markdown_no_fail(
|
||||
message_context.room(),
|
||||
&strings::access::failed_to_parse_patterns(&err.to_string()),
|
||||
MessageResponseType::Reply(message_context.thread_info().root_event_id.clone()),
|
||||
)
|
||||
.await;
|
||||
if let Some(patterns) = patterns
|
||||
&& let Err(err) = mxidwc::parse_patterns_vector(patterns)
|
||||
{
|
||||
bot.messaging()
|
||||
.send_error_markdown_no_fail(
|
||||
message_context.room(),
|
||||
&strings::access::failed_to_parse_patterns(&err.to_string()),
|
||||
MessageResponseType::Reply(message_context.thread_info().root_event_id.clone()),
|
||||
)
|
||||
.await;
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut global_config_manager_guard = bot.global_config_manager().lock().await;
|
||||
|
||||
@@ -15,7 +15,7 @@ use crate::{Bot, entity::MessageContext};
|
||||
|
||||
struct ParsedAgentConfig {
|
||||
agent: AgentInstance,
|
||||
config: serde_yaml::Value,
|
||||
config: serde_yaml_ng::Value,
|
||||
}
|
||||
|
||||
pub async fn handle_room_local(
|
||||
@@ -250,7 +250,7 @@ async fn send_guide(
|
||||
provider: &AgentProvider,
|
||||
) -> anyhow::Result<()> {
|
||||
let sample_config = crate::agent::default_config_for_provider(provider);
|
||||
let sample_config_pretty_yaml = serde_yaml::to_string(&sample_config)?;
|
||||
let sample_config_pretty_yaml = serde_yaml_ng::to_string(&sample_config)?;
|
||||
|
||||
bot.messaging()
|
||||
.send_text_markdown_no_fail(
|
||||
@@ -263,7 +263,7 @@ async fn send_guide(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_from_message_to_yaml_value(text: &str) -> Result<serde_yaml::Value, String> {
|
||||
fn parse_from_message_to_yaml_value(text: &str) -> Result<serde_yaml_ng::Value, String> {
|
||||
let mut text = text.trim();
|
||||
|
||||
if text.starts_with("```") {
|
||||
@@ -274,10 +274,10 @@ fn parse_from_message_to_yaml_value(text: &str) -> Result<serde_yaml::Value, Str
|
||||
text = text.trim_end_matches("```");
|
||||
}
|
||||
|
||||
let config: serde_yaml::Value = serde_yaml::from_str(text).map_err(|e| e.to_string())?;
|
||||
let config: serde_yaml_ng::Value = serde_yaml_ng::from_str(text).map_err(|e| e.to_string())?;
|
||||
|
||||
match config {
|
||||
serde_yaml::Value::Mapping(_) => {}
|
||||
serde_yaml_ng::Value::Mapping(_) => {}
|
||||
_ => {
|
||||
return Err("Not a valid YAML hashmap".to_owned());
|
||||
}
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
fn agent_config_parsing_works() {
|
||||
struct TestCase {
|
||||
input: String,
|
||||
expected: Option<serde_yaml::Value>,
|
||||
expected: Option<serde_yaml_ng::Value>,
|
||||
}
|
||||
|
||||
let provider = crate::agent::AgentProvider::OpenAI;
|
||||
let sample_config = crate::agent::default_config_for_provider(&provider);
|
||||
let sample_config_pretty_yaml = serde_yaml::to_string(&sample_config).unwrap();
|
||||
let sample_config_pretty_yaml = serde_yaml_ng::to_string(&sample_config).unwrap();
|
||||
|
||||
let test_cases = vec![
|
||||
// Invalid input
|
||||
|
||||
@@ -64,7 +64,7 @@ pub async fn handle(
|
||||
PublicIdentifier::Static(_) => {}
|
||||
};
|
||||
|
||||
let config_yaml_pretty = serde_yaml::to_string(&agent.definition().config)?;
|
||||
let config_yaml_pretty = serde_yaml_ng::to_string(&agent.definition().config)?;
|
||||
|
||||
bot.messaging()
|
||||
.send_text_markdown_no_fail(
|
||||
|
||||
@@ -3,7 +3,8 @@ use crate::{
|
||||
entity::roomconfig::{
|
||||
SpeechToTextFlowType, SpeechToTextMessageTypeForNonThreadedOnlyTranscribedMessages,
|
||||
TextGenerationAutoUsage, TextGenerationPrefixRequirementType,
|
||||
TextToSpeechBotMessagesFlowType, TextToSpeechUserMessagesFlowType,
|
||||
TextGenerationSenderContextMode, TextToSpeechBotMessagesFlowType,
|
||||
TextToSpeechUserMessagesFlowType,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -48,6 +49,9 @@ pub enum ConfigTextGenerationSettingRelatedControllerType {
|
||||
|
||||
GetTemperatureOverride,
|
||||
SetTemperatureOverride(Option<f32>),
|
||||
|
||||
GetSenderContextMode,
|
||||
SetSenderContextMode(Option<TextGenerationSenderContextMode>),
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
|
||||
@@ -163,6 +163,26 @@ fn determine_controller() {
|
||||
),
|
||||
)),
|
||||
},
|
||||
TestCase {
|
||||
name: "per-room text-generation/sender-context-mode getter",
|
||||
input: "room text-generation sender-context-mode",
|
||||
expected: super::ControllerType::Config(controller_type::ConfigControllerType::SettingsRelated(
|
||||
controller_type::SettingsStorageSource::Room,
|
||||
controller_type::ConfigSettingRelatedControllerType::TextGeneration(
|
||||
controller_type::ConfigTextGenerationSettingRelatedControllerType::GetSenderContextMode,
|
||||
),
|
||||
)),
|
||||
},
|
||||
TestCase {
|
||||
name: "global text-generation/sender-context-mode getter",
|
||||
input: "global text-generation sender-context-mode",
|
||||
expected: super::ControllerType::Config(controller_type::ConfigControllerType::SettingsRelated(
|
||||
controller_type::SettingsStorageSource::Global,
|
||||
controller_type::ConfigSettingRelatedControllerType::TextGeneration(
|
||||
controller_type::ConfigTextGenerationSettingRelatedControllerType::GetSenderContextMode,
|
||||
),
|
||||
)),
|
||||
},
|
||||
TestCase {
|
||||
name: "per-room text-to-speech/speed-override getter",
|
||||
input: "room text-to-speech speed-override",
|
||||
|
||||
@@ -3,7 +3,10 @@ mod tests;
|
||||
|
||||
use crate::{
|
||||
controller::ControllerType,
|
||||
entity::roomconfig::{TextGenerationAutoUsage, TextGenerationPrefixRequirementType},
|
||||
entity::roomconfig::{
|
||||
TextGenerationAutoUsage, TextGenerationPrefixRequirementType,
|
||||
TextGenerationSenderContextMode,
|
||||
},
|
||||
strings,
|
||||
};
|
||||
|
||||
@@ -197,5 +200,43 @@ pub(super) fn determine(
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(remaining_text) = text.strip_prefix("sender-context-mode") {
|
||||
let remaining_text = remaining_text.trim();
|
||||
|
||||
if !remaining_text.is_empty() {
|
||||
return Err(ControllerType::Error(
|
||||
strings::cfg::configuration_getter_used_with_extra_text(
|
||||
"sender-context-mode",
|
||||
remaining_text,
|
||||
)
|
||||
.to_owned(),
|
||||
));
|
||||
}
|
||||
|
||||
return Ok(ConfigTextGenerationSettingRelatedControllerType::GetSenderContextMode);
|
||||
}
|
||||
|
||||
if let Some(value_string) = text.strip_prefix("set-sender-context-mode") {
|
||||
let value_string = value_string.trim().to_owned();
|
||||
let value_choice = if value_string.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let value_choice =
|
||||
TextGenerationSenderContextMode::from_str(&value_string.to_lowercase());
|
||||
|
||||
if value_choice.is_none() {
|
||||
return Err(ControllerType::Error(
|
||||
strings::cfg::configuration_value_unrecognized(&value_string).to_owned(),
|
||||
));
|
||||
}
|
||||
|
||||
value_choice
|
||||
};
|
||||
|
||||
return Ok(
|
||||
ConfigTextGenerationSettingRelatedControllerType::SetSenderContextMode(value_choice),
|
||||
);
|
||||
}
|
||||
|
||||
Err(ControllerType::Unknown)
|
||||
}
|
||||
|
||||
@@ -90,6 +90,74 @@ fn determine_controller_context_management() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn determine_controller_sender_context() {
|
||||
use super::ConfigTextGenerationSettingRelatedControllerType;
|
||||
use super::ControllerType;
|
||||
use crate::entity::roomconfig::TextGenerationSenderContextMode;
|
||||
|
||||
struct TestCase {
|
||||
name: &'static str,
|
||||
input: &'static str,
|
||||
expected: Result<ConfigTextGenerationSettingRelatedControllerType, ControllerType>,
|
||||
}
|
||||
|
||||
let test_cases = vec![
|
||||
TestCase {
|
||||
name: "sender-context-mode getter ok",
|
||||
input: "sender-context-mode",
|
||||
expected: Ok(ConfigTextGenerationSettingRelatedControllerType::GetSenderContextMode),
|
||||
},
|
||||
TestCase {
|
||||
name: "sender-context-mode getter extra args",
|
||||
input: "sender-context-mode some values here",
|
||||
expected: Err(ControllerType::Error(
|
||||
crate::strings::cfg::configuration_getter_used_with_extra_text(
|
||||
"sender-context-mode",
|
||||
"some values here",
|
||||
),
|
||||
)),
|
||||
},
|
||||
TestCase {
|
||||
name: "sender-context-mode setter matrix_user_id",
|
||||
input: "set-sender-context-mode matrix_user_id",
|
||||
expected: Ok(
|
||||
ConfigTextGenerationSettingRelatedControllerType::SetSenderContextMode(Some(
|
||||
TextGenerationSenderContextMode::MatrixUserId,
|
||||
)),
|
||||
),
|
||||
},
|
||||
TestCase {
|
||||
name: "sender-context-mode setter uppercase",
|
||||
input: "set-sender-context-mode MATRIX_USER_ID_AND_TIMESTAMP",
|
||||
expected: Ok(
|
||||
ConfigTextGenerationSettingRelatedControllerType::SetSenderContextMode(Some(
|
||||
TextGenerationSenderContextMode::MatrixUserIdAndTimestamp,
|
||||
)),
|
||||
),
|
||||
},
|
||||
TestCase {
|
||||
name: "sender-context-mode setter invalid",
|
||||
input: "set-sender-context-mode non-Enum-Value",
|
||||
expected: Err(ControllerType::Error(
|
||||
crate::strings::cfg::configuration_value_unrecognized("non-Enum-Value"),
|
||||
)),
|
||||
},
|
||||
TestCase {
|
||||
name: "sender-context-mode unsetter",
|
||||
input: "set-sender-context-mode",
|
||||
expected: Ok(
|
||||
ConfigTextGenerationSettingRelatedControllerType::SetSenderContextMode(None),
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
for test_case in test_cases {
|
||||
let result = super::determine(test_case.input);
|
||||
assert_eq!(result, test_case.expected, "Test case: {}", test_case.name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn determine_controller_prefix_requirement_type() {
|
||||
use super::ConfigTextGenerationSettingRelatedControllerType;
|
||||
|
||||
@@ -39,18 +39,18 @@ async fn dispatch_config_related_handler(
|
||||
message_context: &MessageContext,
|
||||
bot: &Bot,
|
||||
) -> anyhow::Result<()> {
|
||||
if let SettingsStorageSource::Global = config_type {
|
||||
if !message_context.sender_can_manage_global_config() {
|
||||
bot.messaging()
|
||||
.send_error_markdown_no_fail(
|
||||
message_context.room(),
|
||||
strings::global_config::no_permissions_to_administrate(),
|
||||
MessageResponseType::Reply(message_context.thread_info().root_event_id.clone()),
|
||||
)
|
||||
.await;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
if let SettingsStorageSource::Global = config_type
|
||||
&& !message_context.sender_can_manage_global_config()
|
||||
{
|
||||
bot.messaging()
|
||||
.send_error_markdown_no_fail(
|
||||
message_context.room(),
|
||||
strings::global_config::no_permissions_to_administrate(),
|
||||
MessageResponseType::Reply(message_context.thread_info().root_event_id.clone()),
|
||||
)
|
||||
.await;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let room_settings = match config_type {
|
||||
SettingsStorageSource::Room => &message_context.room_config().settings,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::entity::roomconfig::{
|
||||
RoomSettings, TextGenerationAutoUsage, TextGenerationPrefixRequirementType,
|
||||
TextGenerationSenderContextMode,
|
||||
};
|
||||
use crate::{Bot, entity::MessageContext};
|
||||
|
||||
@@ -151,5 +152,38 @@ pub(super) async fn dispatch(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ConfigTextGenerationSettingRelatedControllerType::GetSenderContextMode => {
|
||||
let value = &room_settings.text_generation.sender_context_mode;
|
||||
setting_get::<TextGenerationSenderContextMode>(bot, message_context, value).await
|
||||
}
|
||||
ConfigTextGenerationSettingRelatedControllerType::SetSenderContextMode(value) => {
|
||||
let value = value.to_owned();
|
||||
|
||||
let setter_callback = Box::new(move |room_settings: &mut RoomSettings| {
|
||||
room_settings.text_generation.sender_context_mode = value;
|
||||
});
|
||||
|
||||
match config_type {
|
||||
SettingsStorageSource::Room => {
|
||||
room_setting_set::<TextGenerationSenderContextMode>(
|
||||
bot,
|
||||
message_context,
|
||||
&value,
|
||||
setter_callback,
|
||||
)
|
||||
.await
|
||||
}
|
||||
SettingsStorageSource::Global => {
|
||||
global_setting_set::<TextGenerationSenderContextMode>(
|
||||
bot,
|
||||
message_context,
|
||||
&value,
|
||||
setter_callback,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@ use crate::{
|
||||
roomconfig::{
|
||||
SpeechToTextFlowType, SpeechToTextMessageTypeForNonThreadedOnlyTranscribedMessages,
|
||||
TextGenerationAutoUsage, TextGenerationPrefixRequirementType,
|
||||
TextToSpeechBotMessagesFlowType, TextToSpeechUserMessagesFlowType,
|
||||
TextGenerationSenderContextMode, TextToSpeechBotMessagesFlowType,
|
||||
TextToSpeechUserMessagesFlowType,
|
||||
},
|
||||
},
|
||||
strings,
|
||||
@@ -233,6 +234,46 @@ fn build_section_text_generation(command_prefix: &str, bot_username: &str) -> St
|
||||
));
|
||||
message.push_str("\n\n");
|
||||
|
||||
// Sender Context
|
||||
|
||||
message.push_str(&format!(
|
||||
"#### {}",
|
||||
strings::help::cfg::text_generation_sender_context_heading()
|
||||
));
|
||||
message.push_str("\n\n");
|
||||
message.push_str(&strings::help::cfg::text_generation_sender_context_intro());
|
||||
message.push('\n');
|
||||
message.push_str(
|
||||
&strings::help::cfg::the_following_configuration_values_are_recognized(
|
||||
TextGenerationSenderContextMode::choices(),
|
||||
),
|
||||
);
|
||||
message.push_str("\n\n");
|
||||
message.push_str(&format!(
|
||||
"- {}",
|
||||
&strings::help::cfg::current_setting_show(
|
||||
command_prefix,
|
||||
"text-generation sender-context-mode"
|
||||
)
|
||||
));
|
||||
message.push('\n');
|
||||
message.push_str(&format!(
|
||||
"- {}",
|
||||
&strings::help::cfg::current_setting_set(
|
||||
command_prefix,
|
||||
"text-generation set-sender-context-mode VALUE"
|
||||
)
|
||||
));
|
||||
message.push('\n');
|
||||
message.push_str(&format!(
|
||||
"- {}",
|
||||
&strings::help::cfg::current_setting_unset(
|
||||
command_prefix,
|
||||
"text-generation set-sender-context-mode"
|
||||
)
|
||||
));
|
||||
message.push_str("\n\n");
|
||||
|
||||
// Prompt override
|
||||
|
||||
message.push_str(&format!(
|
||||
|
||||
@@ -359,6 +359,33 @@ async fn generate_text_generation_section(
|
||||
),
|
||||
);
|
||||
|
||||
// Sender Context
|
||||
|
||||
let effective_sender_context = room_config_context.text_generation_sender_context_mode();
|
||||
let room_config_sender_context = room_config_context
|
||||
.room_config
|
||||
.settings
|
||||
.text_generation
|
||||
.sender_context_mode;
|
||||
let global_config_sender_context = room_config_context
|
||||
.global_config
|
||||
.fallback_room_settings
|
||||
.text_generation
|
||||
.sender_context_mode;
|
||||
|
||||
let sender_context_set_where = if room_config_sender_context.is_some() {
|
||||
strings::cfg::status_badge_set_in_room_config()
|
||||
} else if global_config_sender_context.is_some() {
|
||||
strings::cfg::status_badge_set_in_global_config()
|
||||
} else {
|
||||
strings::cfg::status_badge_using_hardcoded_default()
|
||||
};
|
||||
|
||||
message.push_str(&strings::cfg::status_text_generation_entry_sender_context(
|
||||
effective_sender_context,
|
||||
sender_context_set_where,
|
||||
));
|
||||
|
||||
// Prompt override
|
||||
|
||||
let text_agent_prompt = if let Some(text_generation_agent) = &text_generation_agent {
|
||||
|
||||
@@ -15,7 +15,8 @@ use crate::conversation::matrix::MatrixMessageProcessingParams;
|
||||
use crate::entity::MessagePayload;
|
||||
use crate::entity::roomconfig::{
|
||||
SpeechToTextFlowType, SpeechToTextMessageTypeForNonThreadedOnlyTranscribedMessages,
|
||||
TextToSpeechBotMessagesFlowType, TextToSpeechUserMessagesFlowType,
|
||||
TextGenerationSenderContextMode, TextToSpeechBotMessagesFlowType,
|
||||
TextToSpeechUserMessagesFlowType,
|
||||
};
|
||||
use crate::strings;
|
||||
use crate::utils::text_to_speech::create_transcribed_message_text;
|
||||
@@ -23,6 +24,7 @@ use crate::{
|
||||
Bot,
|
||||
conversation::{
|
||||
create_llm_conversation_for_matrix_reply_chain, create_llm_conversation_for_matrix_thread,
|
||||
llm::{Author, Conversation, MessageContent},
|
||||
matrix::create_list_of_bot_user_prefixes_to_strip,
|
||||
},
|
||||
entity::MessageContext,
|
||||
@@ -41,6 +43,8 @@ pub enum ChatCompletionControllerType {
|
||||
|
||||
Image,
|
||||
|
||||
File,
|
||||
|
||||
ThreadMention,
|
||||
ReplyMention,
|
||||
}
|
||||
@@ -419,7 +423,8 @@ async fn handle_stage_text_generation(
|
||||
| ChatCompletionControllerType::TextMention
|
||||
| ChatCompletionControllerType::TextDirect
|
||||
| ChatCompletionControllerType::Audio
|
||||
| ChatCompletionControllerType::Image => {
|
||||
| ChatCompletionControllerType::Image
|
||||
| ChatCompletionControllerType::File => {
|
||||
Some(message_context.combined_admin_and_user_regexes())
|
||||
}
|
||||
|
||||
@@ -483,6 +488,13 @@ async fn handle_stage_text_generation(
|
||||
}
|
||||
};
|
||||
|
||||
let conversation = inject_sender_context(
|
||||
conversation,
|
||||
message_context
|
||||
.room_config_context()
|
||||
.text_generation_sender_context_mode(),
|
||||
);
|
||||
|
||||
tracing::debug!(
|
||||
agent_id = agent.identifier().as_string(),
|
||||
provider = format!("{}", agent.definition().provider.clone()),
|
||||
@@ -758,3 +770,238 @@ async fn generate_and_send_tts_for_message(
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn inject_sender_context(
|
||||
conversation: Conversation,
|
||||
sender_context_mode: TextGenerationSenderContextMode,
|
||||
) -> Conversation {
|
||||
if sender_context_mode == TextGenerationSenderContextMode::Disabled {
|
||||
return conversation;
|
||||
}
|
||||
|
||||
let include_timestamp =
|
||||
sender_context_mode == TextGenerationSenderContextMode::MatrixUserIdAndTimestamp;
|
||||
|
||||
let messages = conversation
|
||||
.messages
|
||||
.into_iter()
|
||||
.map(|mut message| {
|
||||
if message.author == Author::Prompt {
|
||||
return message;
|
||||
}
|
||||
|
||||
let Some(sender_id) = &message.sender_id else {
|
||||
return message;
|
||||
};
|
||||
|
||||
if let MessageContent::Text(ref mut text) = message.content {
|
||||
*text = if include_timestamp {
|
||||
let timestamp = message.timestamp.format("%Y-%m-%dT%H:%M:%SZ");
|
||||
format!("[sender={} sent_at={}] {}", sender_id, timestamp, text)
|
||||
} else {
|
||||
format!("[sender={}] {}", sender_id, text)
|
||||
};
|
||||
}
|
||||
|
||||
message
|
||||
})
|
||||
.collect();
|
||||
|
||||
Conversation { messages }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod sender_context_tests {
|
||||
use super::inject_sender_context;
|
||||
use crate::conversation::llm::{Author, Conversation, ImageDetails, Message, MessageContent};
|
||||
use crate::entity::roomconfig::TextGenerationSenderContextMode;
|
||||
use chrono::{TimeZone, Utc};
|
||||
use mxlink::matrix_sdk::ruma::events::room::message::ImageMessageEventContent;
|
||||
use mxlink::matrix_sdk::ruma::{OwnedMxcUri, OwnedUserId};
|
||||
use mxlink::mime;
|
||||
|
||||
#[test]
|
||||
fn test_inject_sender_context_prefixes_text_messages() {
|
||||
let timestamp = Utc.with_ymd_and_hms(2026, 3, 23, 14, 30, 0).unwrap();
|
||||
let user_id = OwnedUserId::try_from("@alice:example.com").unwrap();
|
||||
|
||||
let conversation = Conversation {
|
||||
messages: vec![Message {
|
||||
author: Author::User,
|
||||
sender_id: Some(user_id),
|
||||
timestamp,
|
||||
content: MessageContent::Text("Hello bot".to_string()),
|
||||
}],
|
||||
};
|
||||
|
||||
let result = inject_sender_context(
|
||||
conversation,
|
||||
TextGenerationSenderContextMode::MatrixUserIdAndTimestamp,
|
||||
);
|
||||
|
||||
assert_eq!(result.messages.len(), 1);
|
||||
assert_eq!(
|
||||
result.messages[0].content,
|
||||
MessageContent::Text(
|
||||
"[sender=@alice:example.com sent_at=2026-03-23T14:30:00Z] Hello bot".to_string()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inject_sender_context_can_prefix_without_timestamp() {
|
||||
let timestamp = Utc.with_ymd_and_hms(2026, 3, 23, 14, 30, 0).unwrap();
|
||||
let user_id = OwnedUserId::try_from("@alice:example.com").unwrap();
|
||||
|
||||
let conversation = Conversation {
|
||||
messages: vec![Message {
|
||||
author: Author::User,
|
||||
sender_id: Some(user_id),
|
||||
timestamp,
|
||||
content: MessageContent::Text("Hello bot".to_string()),
|
||||
}],
|
||||
};
|
||||
|
||||
let result =
|
||||
inject_sender_context(conversation, TextGenerationSenderContextMode::MatrixUserId);
|
||||
|
||||
assert_eq!(result.messages.len(), 1);
|
||||
assert_eq!(
|
||||
result.messages[0].content,
|
||||
MessageContent::Text("[sender=@alice:example.com] Hello bot".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inject_sender_context_prefixes_assistant_messages() {
|
||||
let timestamp = Utc.with_ymd_and_hms(2026, 3, 23, 14, 30, 0).unwrap();
|
||||
let user_id = OwnedUserId::try_from("@baibot:example.com").unwrap();
|
||||
|
||||
let conversation = Conversation {
|
||||
messages: vec![Message {
|
||||
author: Author::Assistant,
|
||||
sender_id: Some(user_id),
|
||||
timestamp,
|
||||
content: MessageContent::Text("Hello human".to_string()),
|
||||
}],
|
||||
};
|
||||
|
||||
let result = inject_sender_context(
|
||||
conversation,
|
||||
TextGenerationSenderContextMode::MatrixUserIdAndTimestamp,
|
||||
);
|
||||
|
||||
assert_eq!(result.messages.len(), 1);
|
||||
assert_eq!(
|
||||
result.messages[0].content,
|
||||
MessageContent::Text(
|
||||
"[sender=@baibot:example.com sent_at=2026-03-23T14:30:00Z] Hello human".to_string()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inject_sender_context_skips_prompt_messages() {
|
||||
let timestamp = Utc.with_ymd_and_hms(2026, 3, 23, 14, 30, 0).unwrap();
|
||||
|
||||
let conversation = Conversation {
|
||||
messages: vec![Message {
|
||||
author: Author::Prompt,
|
||||
sender_id: None,
|
||||
timestamp,
|
||||
content: MessageContent::Text("You are a bot".to_string()),
|
||||
}],
|
||||
};
|
||||
|
||||
let result =
|
||||
inject_sender_context(conversation, TextGenerationSenderContextMode::MatrixUserId);
|
||||
|
||||
assert_eq!(
|
||||
result.messages[0].content,
|
||||
MessageContent::Text("You are a bot".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inject_sender_context_skips_messages_without_sender_id() {
|
||||
let timestamp = Utc.with_ymd_and_hms(2026, 3, 23, 14, 30, 0).unwrap();
|
||||
|
||||
let conversation = Conversation {
|
||||
messages: vec![Message {
|
||||
author: Author::User,
|
||||
sender_id: None,
|
||||
timestamp,
|
||||
content: MessageContent::Text("Transcribed text".to_string()),
|
||||
}],
|
||||
};
|
||||
|
||||
let result = inject_sender_context(
|
||||
conversation,
|
||||
TextGenerationSenderContextMode::MatrixUserIdAndTimestamp,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
result.messages[0].content,
|
||||
MessageContent::Text("Transcribed text".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inject_sender_context_leaves_non_text_content_unchanged() {
|
||||
let timestamp = Utc.with_ymd_and_hms(2026, 3, 23, 14, 30, 0).unwrap();
|
||||
let user_id = OwnedUserId::try_from("@alice:example.com").unwrap();
|
||||
let image_event_content = ImageMessageEventContent::plain(
|
||||
"image.png".to_string(),
|
||||
OwnedMxcUri::from("mxc://example.com/1234567890"),
|
||||
);
|
||||
|
||||
let conversation = Conversation {
|
||||
messages: vec![Message {
|
||||
author: Author::User,
|
||||
sender_id: Some(user_id),
|
||||
timestamp,
|
||||
content: MessageContent::Image(ImageDetails::new(
|
||||
image_event_content.clone(),
|
||||
mime::IMAGE_PNG,
|
||||
vec![],
|
||||
)),
|
||||
}],
|
||||
};
|
||||
|
||||
let result = inject_sender_context(
|
||||
conversation,
|
||||
TextGenerationSenderContextMode::MatrixUserIdAndTimestamp,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
result.messages[0].content,
|
||||
MessageContent::Image(ImageDetails::new(
|
||||
image_event_content,
|
||||
mime::IMAGE_PNG,
|
||||
vec![]
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inject_sender_context_none_leaves_text_unchanged() {
|
||||
let timestamp = Utc.with_ymd_and_hms(2026, 3, 23, 14, 30, 0).unwrap();
|
||||
let user_id = OwnedUserId::try_from("@alice:example.com").unwrap();
|
||||
|
||||
let conversation = Conversation {
|
||||
messages: vec![Message {
|
||||
author: Author::User,
|
||||
sender_id: Some(user_id),
|
||||
timestamp,
|
||||
content: MessageContent::Text("Hello bot".to_string()),
|
||||
}],
|
||||
};
|
||||
|
||||
let result = inject_sender_context(conversation, TextGenerationSenderContextMode::Disabled);
|
||||
|
||||
assert_eq!(
|
||||
result.messages[0].content,
|
||||
MessageContent::Text("Hello bot".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,18 @@ pub fn determine_controller(
|
||||
)
|
||||
}
|
||||
}
|
||||
MessagePayload::File(_file_message_content) => {
|
||||
let prefix_requirement_type = message_context
|
||||
.room_config_context()
|
||||
.text_generation_prefix_requirement_type();
|
||||
|
||||
match prefix_requirement_type {
|
||||
TextGenerationPrefixRequirementType::CommandPrefix => ControllerType::Ignore,
|
||||
TextGenerationPrefixRequirementType::No => {
|
||||
ControllerType::ChatCompletion(ChatCompletionControllerType::File)
|
||||
}
|
||||
}
|
||||
}
|
||||
MessagePayload::Audio(_) => {
|
||||
ControllerType::ChatCompletion(ChatCompletionControllerType::Audio)
|
||||
}
|
||||
|
||||
@@ -12,9 +12,6 @@ use crate::strings;
|
||||
use crate::utils::mime::get_file_extension;
|
||||
use crate::{Bot, entity::MessageContext};
|
||||
|
||||
// We may make this configurable (per room, etc.) in the future, but for now it's hardcoded.
|
||||
const STICKER_SIZE: &str = "256x256";
|
||||
|
||||
pub async fn handle_image(
|
||||
bot: &Bot,
|
||||
matrix_link: MatrixLink,
|
||||
@@ -177,7 +174,7 @@ pub async fn handle_sticker(
|
||||
);
|
||||
|
||||
let params = ImageGenerationParams::default()
|
||||
.with_size_override(Some(STICKER_SIZE.to_owned()))
|
||||
.with_smallest_size_possible(true)
|
||||
.with_cheaper_model_switching_allowed(true)
|
||||
.with_cheaper_quality_switching_allowed(true);
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ mod tests {
|
||||
original_prompt: "Generate a picture of a dog",
|
||||
messages: vec![Message {
|
||||
author: Author::User,
|
||||
sender_id: None,
|
||||
content: MessageContent::Text("Must be blue".to_owned()),
|
||||
timestamp,
|
||||
}],
|
||||
@@ -75,16 +76,19 @@ mod tests {
|
||||
messages: vec![
|
||||
Message {
|
||||
author: Author::User,
|
||||
sender_id: None,
|
||||
content: MessageContent::Text("Must be blue".to_owned()),
|
||||
timestamp,
|
||||
},
|
||||
Message {
|
||||
author: Author::Assistant,
|
||||
sender_id: None,
|
||||
content: MessageContent::Text("Whatever".to_owned()),
|
||||
timestamp,
|
||||
},
|
||||
Message {
|
||||
author: Author::User,
|
||||
sender_id: None,
|
||||
content: MessageContent::Text(
|
||||
"Must be 3-legged.\nMust be flying.".to_owned(),
|
||||
),
|
||||
@@ -99,21 +103,25 @@ mod tests {
|
||||
messages: vec![
|
||||
Message {
|
||||
author: Author::User,
|
||||
sender_id: None,
|
||||
content: MessageContent::Text("Must be blue".to_owned()),
|
||||
timestamp,
|
||||
},
|
||||
Message {
|
||||
author: Author::Assistant,
|
||||
sender_id: None,
|
||||
content: MessageContent::Text("Whatever".to_owned()),
|
||||
timestamp,
|
||||
},
|
||||
Message {
|
||||
author: Author::User,
|
||||
sender_id: None,
|
||||
content: MessageContent::Text("Again".to_owned()),
|
||||
timestamp,
|
||||
},
|
||||
Message {
|
||||
author: Author::User,
|
||||
sender_id: None,
|
||||
content: MessageContent::Text("again".to_owned()),
|
||||
timestamp,
|
||||
},
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use mxlink::matrix_sdk::ruma::events::room::message::ImageMessageEventContent;
|
||||
use mxlink::matrix_sdk::ruma::OwnedUserId;
|
||||
use mxlink::matrix_sdk::ruma::events::room::message::{
|
||||
FileMessageEventContent, ImageMessageEventContent,
|
||||
};
|
||||
use mxlink::mime::Mime;
|
||||
|
||||
use crate::agent::provider::ImageSource;
|
||||
@@ -14,6 +17,7 @@ pub enum Author {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Message {
|
||||
pub author: Author,
|
||||
pub sender_id: Option<OwnedUserId>,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
pub content: MessageContent,
|
||||
}
|
||||
@@ -48,10 +52,35 @@ impl From<ImageDetails> for ImageSource {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileDetails {
|
||||
pub event_content: FileMessageEventContent,
|
||||
pub mime: Mime,
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
impl FileDetails {
|
||||
pub fn new(event_content: FileMessageEventContent, mime: Mime, data: Vec<u8>) -> Self {
|
||||
Self {
|
||||
event_content,
|
||||
mime,
|
||||
data,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn filename(&self) -> String {
|
||||
self.event_content
|
||||
.filename
|
||||
.clone()
|
||||
.unwrap_or(self.event_content.body.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum MessageContent {
|
||||
Text(String),
|
||||
Image(ImageDetails),
|
||||
File(FileDetails),
|
||||
}
|
||||
|
||||
impl PartialEq for MessageContent {
|
||||
@@ -62,6 +91,7 @@ impl PartialEq for MessageContent {
|
||||
// We can probably do better than this by inspecting `.event_conten1t.source`, but for now this is good enough.
|
||||
a.filename() == b.filename()
|
||||
}
|
||||
(MessageContent::File(a), MessageContent::File(b)) => a.filename() == b.filename(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -76,6 +106,11 @@ impl Conversation {
|
||||
///
|
||||
/// Certain models (like Anthropic) cannot tolerate consecutive messages by the same author,
|
||||
/// so combining them helps avoid issues.
|
||||
///
|
||||
/// When multiple text messages by the same author are merged, the resulting message keeps a
|
||||
/// `sender_id` only if all merged messages came from the same sender. Mixed-sender merges are
|
||||
/// possible for user turns in multi-user rooms, so `sender_id` is cleared in that case to
|
||||
/// avoid incorrectly attributing the whole merged turn to the first sender.
|
||||
/// See: https://github.com/etkecc/baibot/issues/13
|
||||
pub fn combine_consecutive_messages(&self) -> Conversation {
|
||||
// We'll likely get fewer messages, but let's reserve the maximum we expect.
|
||||
@@ -106,6 +141,10 @@ impl Conversation {
|
||||
text.push('\n');
|
||||
text.push_str(message_text_content);
|
||||
}
|
||||
|
||||
if last_message.sender_id != message.sender_id {
|
||||
last_message.sender_id = None;
|
||||
}
|
||||
}
|
||||
|
||||
Conversation {
|
||||
@@ -122,7 +161,7 @@ impl Conversation {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::{TimeZone, Utc};
|
||||
use mxlink::matrix_sdk::ruma::OwnedMxcUri;
|
||||
use mxlink::matrix_sdk::ruma::{OwnedMxcUri, OwnedUserId};
|
||||
use mxlink::mime;
|
||||
|
||||
#[test]
|
||||
@@ -145,21 +184,25 @@ mod tests {
|
||||
// User's turn
|
||||
Message {
|
||||
author: Author::User,
|
||||
sender_id: None,
|
||||
content: MessageContent::Text("Hello".to_string()),
|
||||
timestamp: timestamp_1,
|
||||
},
|
||||
Message {
|
||||
author: Author::User,
|
||||
sender_id: None,
|
||||
content: MessageContent::Text("How are you?".to_string()),
|
||||
timestamp: timestamp_2,
|
||||
},
|
||||
Message {
|
||||
author: Author::User,
|
||||
sender_id: None,
|
||||
content: MessageContent::Text("I'm OK, btw.".to_string()),
|
||||
timestamp: timestamp_3,
|
||||
},
|
||||
Message {
|
||||
author: Author::User,
|
||||
sender_id: None,
|
||||
content: MessageContent::Image(ImageDetails::new(
|
||||
image_event_content.clone(),
|
||||
mime::IMAGE_PNG,
|
||||
@@ -169,28 +212,33 @@ mod tests {
|
||||
},
|
||||
Message {
|
||||
author: Author::User,
|
||||
sender_id: None,
|
||||
content: MessageContent::Text("Above is an image.".to_string()),
|
||||
timestamp: timestamp_4,
|
||||
},
|
||||
Message {
|
||||
author: Author::User,
|
||||
sender_id: None,
|
||||
content: MessageContent::Text("Would you take a look at it?".to_string()),
|
||||
timestamp: timestamp_4,
|
||||
},
|
||||
// Assistant's turn
|
||||
Message {
|
||||
author: Author::Assistant,
|
||||
sender_id: None,
|
||||
content: MessageContent::Text("Hi there!".to_string()),
|
||||
timestamp: timestamp_2,
|
||||
},
|
||||
Message {
|
||||
author: Author::Assistant,
|
||||
sender_id: None,
|
||||
content: MessageContent::Text("I'm doing well, thank you.".to_string()),
|
||||
timestamp: timestamp_3,
|
||||
},
|
||||
// User's turn
|
||||
Message {
|
||||
author: Author::User,
|
||||
sender_id: None,
|
||||
content: MessageContent::Text("That's great!".to_string()),
|
||||
timestamp: timestamp_3,
|
||||
},
|
||||
@@ -239,4 +287,39 @@ mod tests {
|
||||
);
|
||||
assert_eq!(conversation.messages[4].timestamp, timestamp_3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combine_consecutive_messages_clears_sender_id_for_mixed_sender_turns() {
|
||||
let timestamp_1 = Utc.with_ymd_and_hms(2024, 9, 20, 18, 34, 15).unwrap();
|
||||
let timestamp_2 = Utc.with_ymd_and_hms(2024, 9, 20, 18, 34, 16).unwrap();
|
||||
let sender_1 = OwnedUserId::try_from("@alice:example.com").unwrap();
|
||||
let sender_2 = OwnedUserId::try_from("@bob:example.com").unwrap();
|
||||
|
||||
let conversation = Conversation {
|
||||
messages: vec![
|
||||
Message {
|
||||
author: Author::User,
|
||||
sender_id: Some(sender_1),
|
||||
content: MessageContent::Text("Hello".to_string()),
|
||||
timestamp: timestamp_1,
|
||||
},
|
||||
Message {
|
||||
author: Author::User,
|
||||
sender_id: Some(sender_2),
|
||||
content: MessageContent::Text("Hi there".to_string()),
|
||||
timestamp: timestamp_2,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let conversation = conversation.combine_consecutive_messages();
|
||||
|
||||
assert_eq!(conversation.messages.len(), 1);
|
||||
assert_eq!(conversation.messages[0].sender_id, None);
|
||||
assert_eq!(
|
||||
conversation.messages[0].content,
|
||||
MessageContent::Text("Hello\nHi there".to_string())
|
||||
);
|
||||
assert_eq!(conversation.messages[0].timestamp, timestamp_1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ fn test_messages_by_the_bot_are_identified_correctly() {
|
||||
let llm_message = convert_matrix_message_to_llm_message(&matrix_message, &bot_user_id).unwrap();
|
||||
|
||||
assert_eq!(llm_message.author, Author::Assistant);
|
||||
assert_eq!(llm_message.sender_id, Some(bot_user_id.clone()));
|
||||
assert_eq!(
|
||||
llm_message.content,
|
||||
MessageContent::Text("Hello!".to_string())
|
||||
@@ -47,6 +48,7 @@ fn test_notice_messages_by_bot_with_speech_to_text_prefix_are_cleaned_up_and_con
|
||||
let llm_message = convert_matrix_message_to_llm_message(&matrix_message, &bot_user_id).unwrap();
|
||||
|
||||
assert_eq!(llm_message.author, Author::User);
|
||||
assert_eq!(llm_message.sender_id, None);
|
||||
assert_eq!(
|
||||
llm_message.content,
|
||||
MessageContent::Text(source_message_text.to_string())
|
||||
@@ -75,6 +77,30 @@ fn test_notice_error_messages_by_bot_are_ignored() {
|
||||
assert!(llm_message.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_user_messages_preserve_sender_id() {
|
||||
let bot_user_id =
|
||||
OwnedUserId::try_from("@bot:example.com").expect("Failed to parse bot user ID");
|
||||
|
||||
let user_id = OwnedUserId::try_from("@alice:example.com").expect("Failed to parse user ID");
|
||||
|
||||
let matrix_message = super::super::matrix::MatrixMessage {
|
||||
sender_id: user_id.clone(),
|
||||
content: super::super::matrix::MatrixMessageContent::Text("Hello!".to_owned()),
|
||||
mentioned_users: vec![],
|
||||
timestamp: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
let llm_message = convert_matrix_message_to_llm_message(&matrix_message, &bot_user_id).unwrap();
|
||||
|
||||
assert_eq!(llm_message.author, Author::User);
|
||||
assert_eq!(llm_message.sender_id, Some(user_id));
|
||||
assert_eq!(
|
||||
llm_message.content,
|
||||
MessageContent::Text("Hello!".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_other_notice_messages_by_the_bot_are_ignored() {
|
||||
// Also see `test_notice_error_messages_by_bot_are_ignored()`.
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
use tiktoken_rs::CoreBPE;
|
||||
use tiktoken_rs::get_bpe_from_tokenizer;
|
||||
use tiktoken_rs::bpe_for_tokenizer;
|
||||
use tiktoken_rs::tokenizer;
|
||||
|
||||
use super::{Author, Message, MessageContent};
|
||||
|
||||
fn get_bpe_for_model(model: &str) -> CoreBPE {
|
||||
fn get_bpe_for_model(model: &str) -> &'static CoreBPE {
|
||||
let tokenizer = tokenizer::get_tokenizer(model)
|
||||
.or_else(|| tokenizer::get_tokenizer("gpt-4"))
|
||||
.unwrap();
|
||||
|
||||
get_bpe_from_tokenizer(tokenizer).unwrap()
|
||||
bpe_for_tokenizer(tokenizer).unwrap()
|
||||
}
|
||||
|
||||
pub fn shorten_messages_list_to_context_size(
|
||||
@@ -26,7 +26,7 @@ pub fn shorten_messages_list_to_context_size(
|
||||
// We want to retain the prompt in all cases, so we always count it first.
|
||||
// We also always reserve enough tokens for the maximum response we expect.
|
||||
let mut current_context_length: u32 = if let Some(prompt_message) = prompt_message {
|
||||
calculate_token_size_for_message(&bpe, model, prompt_message)
|
||||
calculate_token_size_for_message(bpe, model, prompt_message)
|
||||
+ max_response_tokens.unwrap_or(0)
|
||||
} else {
|
||||
0
|
||||
@@ -37,7 +37,7 @@ pub fn shorten_messages_list_to_context_size(
|
||||
let mut messages_to_keep: Vec<Message> = Vec::new();
|
||||
|
||||
for message in messages {
|
||||
let tokens_for_message = calculate_token_size_for_message(&bpe, model, &message);
|
||||
let tokens_for_message = calculate_token_size_for_message(bpe, model, &message);
|
||||
|
||||
if current_context_length + tokens_for_message > max_context_tokens {
|
||||
break;
|
||||
@@ -74,6 +74,7 @@ fn calculate_token_size_for_message(bpe: &CoreBPE, model: &str, message: &Messag
|
||||
let text_length = match &message.content {
|
||||
MessageContent::Text(text) => bpe.encode_with_special_tokens(text).len() as i32,
|
||||
MessageContent::Image(..) => 0,
|
||||
MessageContent::File(..) => 0,
|
||||
};
|
||||
|
||||
(text_length + role_length + tokens_per_message + tokens_per_name) as u32
|
||||
@@ -88,11 +89,12 @@ pub mod test {
|
||||
|
||||
let message = super::Message {
|
||||
author: super::Author::User,
|
||||
sender_id: None,
|
||||
content: super::MessageContent::Text("Hello there!".to_string()),
|
||||
timestamp: chrono::Utc::now(),
|
||||
};
|
||||
|
||||
let tokens = super::calculate_token_size_for_message(&bpe, model, &message);
|
||||
let tokens = super::calculate_token_size_for_message(bpe, model, &message);
|
||||
|
||||
assert_eq!(8, tokens);
|
||||
}
|
||||
@@ -107,6 +109,7 @@ pub mod test {
|
||||
|
||||
let prompt = super::Message {
|
||||
author: super::Author::Prompt,
|
||||
sender_id: None,
|
||||
content: super::MessageContent::Text("You are a bot!".to_string()),
|
||||
timestamp: chrono::Utc::now(),
|
||||
};
|
||||
@@ -114,13 +117,14 @@ pub mod test {
|
||||
|
||||
assert_eq!(
|
||||
prompt_length,
|
||||
super::calculate_token_size_for_message(&bpe, model, &prompt)
|
||||
super::calculate_token_size_for_message(bpe, model, &prompt)
|
||||
);
|
||||
|
||||
let mut conversation_messages = Vec::new();
|
||||
|
||||
let first = super::Message {
|
||||
author: super::Author::User,
|
||||
sender_id: None,
|
||||
content: super::MessageContent::Text("Hello there!".to_string()),
|
||||
timestamp: chrono::Utc::now(),
|
||||
};
|
||||
@@ -128,13 +132,14 @@ pub mod test {
|
||||
|
||||
assert_eq!(
|
||||
first_length,
|
||||
super::calculate_token_size_for_message(&bpe, model, &first)
|
||||
super::calculate_token_size_for_message(bpe, model, &first)
|
||||
);
|
||||
|
||||
conversation_messages.push(first);
|
||||
|
||||
let second = super::Message {
|
||||
author: super::Author::Assistant,
|
||||
sender_id: None,
|
||||
content: super::MessageContent::Text("Hello!".to_string()),
|
||||
timestamp: chrono::Utc::now(),
|
||||
};
|
||||
@@ -142,13 +147,14 @@ pub mod test {
|
||||
|
||||
assert_eq!(
|
||||
second_length,
|
||||
super::calculate_token_size_for_message(&bpe, model, &second)
|
||||
super::calculate_token_size_for_message(bpe, model, &second)
|
||||
);
|
||||
|
||||
conversation_messages.push(second);
|
||||
|
||||
let third = super::Message {
|
||||
author: super::Author::User,
|
||||
sender_id: None,
|
||||
content: super::MessageContent::Text(
|
||||
"This is the 3rd message in this conversation. It shall be preserved.".to_owned(),
|
||||
),
|
||||
@@ -158,13 +164,14 @@ pub mod test {
|
||||
|
||||
assert_eq!(
|
||||
third_length,
|
||||
super::calculate_token_size_for_message(&bpe, model, &third)
|
||||
super::calculate_token_size_for_message(bpe, model, &third)
|
||||
);
|
||||
|
||||
conversation_messages.push(third.clone());
|
||||
|
||||
let forth = super::Message {
|
||||
author: super::Author::Assistant,
|
||||
sender_id: None,
|
||||
content: super::MessageContent::Text(
|
||||
"This is yet another message that shall be preserved.".to_owned(),
|
||||
),
|
||||
@@ -174,7 +181,7 @@ pub mod test {
|
||||
|
||||
assert_eq!(
|
||||
forth_length,
|
||||
super::calculate_token_size_for_message(&bpe, model, &forth)
|
||||
super::calculate_token_size_for_message(bpe, model, &forth)
|
||||
);
|
||||
|
||||
conversation_messages.push(forth.clone());
|
||||
@@ -212,6 +219,7 @@ pub mod test {
|
||||
|
||||
let prompt = super::Message {
|
||||
author: super::Author::User,
|
||||
sender_id: None,
|
||||
content: super::MessageContent::Text("あなたはボットです。".to_string()),
|
||||
timestamp: chrono::Utc::now(),
|
||||
};
|
||||
@@ -219,13 +227,14 @@ pub mod test {
|
||||
|
||||
assert_eq!(
|
||||
prompt_length,
|
||||
super::calculate_token_size_for_message(&bpe, model, &prompt)
|
||||
super::calculate_token_size_for_message(bpe, model, &prompt)
|
||||
);
|
||||
|
||||
let mut conversation_messages = Vec::new();
|
||||
|
||||
let first = super::Message {
|
||||
author: super::Author::User,
|
||||
sender_id: None,
|
||||
content: super::MessageContent::Text("こんにちは!".to_string()),
|
||||
timestamp: chrono::Utc::now(),
|
||||
};
|
||||
@@ -233,13 +242,14 @@ pub mod test {
|
||||
|
||||
assert_eq!(
|
||||
first_length,
|
||||
super::calculate_token_size_for_message(&bpe, model, &first)
|
||||
super::calculate_token_size_for_message(bpe, model, &first)
|
||||
);
|
||||
|
||||
conversation_messages.push(first);
|
||||
|
||||
let second = super::Message {
|
||||
author: super::Author::Assistant,
|
||||
sender_id: None,
|
||||
content: super::MessageContent::Text("こんにちは。今日は元気ですか。".to_string()),
|
||||
timestamp: chrono::Utc::now(),
|
||||
};
|
||||
@@ -247,13 +257,14 @@ pub mod test {
|
||||
|
||||
assert_eq!(
|
||||
second_length,
|
||||
super::calculate_token_size_for_message(&bpe, model, &second)
|
||||
super::calculate_token_size_for_message(bpe, model, &second)
|
||||
);
|
||||
|
||||
conversation_messages.push(second);
|
||||
|
||||
let third = super::Message {
|
||||
author: super::Author::User,
|
||||
sender_id: None,
|
||||
content: super::MessageContent::Text(
|
||||
"これは第3のメッセージなので、保存されます。".to_string(),
|
||||
),
|
||||
@@ -263,13 +274,14 @@ pub mod test {
|
||||
|
||||
assert_eq!(
|
||||
third_length,
|
||||
super::calculate_token_size_for_message(&bpe, model, &third)
|
||||
super::calculate_token_size_for_message(bpe, model, &third)
|
||||
);
|
||||
|
||||
conversation_messages.push(third.clone());
|
||||
|
||||
let forth = super::Message {
|
||||
author: super::Author::Assistant,
|
||||
sender_id: None,
|
||||
content: super::MessageContent::Text(
|
||||
"これはもう一つの保存されますメッセージです。".to_string(),
|
||||
),
|
||||
@@ -279,7 +291,7 @@ pub mod test {
|
||||
|
||||
assert_eq!(
|
||||
forth_length,
|
||||
super::calculate_token_size_for_message(&bpe, model, &forth)
|
||||
super::calculate_token_size_for_message(bpe, model, &forth)
|
||||
);
|
||||
|
||||
conversation_messages.push(forth.clone());
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use mxlink::matrix_sdk::ruma::OwnedUserId;
|
||||
|
||||
use super::entity::{Author, ImageDetails, Message, MessageContent};
|
||||
use super::entity::{Author, FileDetails, ImageDetails, Message, MessageContent};
|
||||
use crate::conversation::matrix::{MatrixMessage, MatrixMessageContent};
|
||||
use crate::utils::text_to_speech as text_to_speech_utils;
|
||||
|
||||
@@ -17,14 +17,17 @@ pub fn convert_matrix_message_to_llm_message(
|
||||
|
||||
fn convert_bot_message(matrix_message: &MatrixMessage) -> Option<Message> {
|
||||
match &matrix_message.content {
|
||||
MatrixMessageContent::Text(text) => {
|
||||
convert_bot_text_message(text, &matrix_message.timestamp)
|
||||
}
|
||||
MatrixMessageContent::Text(text) => convert_bot_text_message(
|
||||
text,
|
||||
&matrix_message.timestamp,
|
||||
matrix_message.sender_id.clone(),
|
||||
),
|
||||
MatrixMessageContent::Notice(text) => {
|
||||
convert_bot_notice_message(text, &matrix_message.timestamp)
|
||||
}
|
||||
MatrixMessageContent::Image(image_content, mime_type, media_bytes) => Some(Message {
|
||||
author: Author::Assistant,
|
||||
sender_id: Some(matrix_message.sender_id.clone()),
|
||||
content: MessageContent::Image(ImageDetails::new(
|
||||
image_content.clone(),
|
||||
mime_type.clone(),
|
||||
@@ -32,15 +35,27 @@ fn convert_bot_message(matrix_message: &MatrixMessage) -> Option<Message> {
|
||||
)),
|
||||
timestamp: matrix_message.timestamp.to_owned(),
|
||||
}),
|
||||
MatrixMessageContent::File(file_content, mime_type, media_bytes) => Some(Message {
|
||||
author: Author::Assistant,
|
||||
sender_id: Some(matrix_message.sender_id.clone()),
|
||||
content: MessageContent::File(FileDetails::new(
|
||||
file_content.clone(),
|
||||
mime_type.clone(),
|
||||
media_bytes.clone(),
|
||||
)),
|
||||
timestamp: matrix_message.timestamp.to_owned(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_bot_text_message(
|
||||
text: &str,
|
||||
timestamp: &chrono::DateTime<chrono::Utc>,
|
||||
sender_id: OwnedUserId,
|
||||
) -> Option<Message> {
|
||||
Some(Message {
|
||||
author: Author::Assistant,
|
||||
sender_id: Some(sender_id),
|
||||
content: MessageContent::Text(text.to_owned()),
|
||||
timestamp: timestamp.to_owned(),
|
||||
})
|
||||
@@ -59,8 +74,10 @@ fn convert_bot_notice_message(
|
||||
|
||||
if let Some(text) = text_to_speech_utils::parse_transcribed_message_text(text) {
|
||||
// This is a transcription message. We remove the prefix and consider it as a message sent by the user.
|
||||
// sender_id is None because the original speaker is unknown.
|
||||
return Some(Message {
|
||||
author: Author::User,
|
||||
sender_id: None,
|
||||
content: MessageContent::Text(text.to_owned()),
|
||||
timestamp: timestamp.to_owned(),
|
||||
});
|
||||
@@ -73,16 +90,19 @@ fn convert_user_message(matrix_message: &MatrixMessage) -> Option<Message> {
|
||||
match &matrix_message.content {
|
||||
MatrixMessageContent::Text(text) => Some(Message {
|
||||
author: Author::User,
|
||||
sender_id: Some(matrix_message.sender_id.clone()),
|
||||
content: MessageContent::Text(text.clone()),
|
||||
timestamp: matrix_message.timestamp.to_owned(),
|
||||
}),
|
||||
MatrixMessageContent::Notice(text) => Some(Message {
|
||||
author: Author::User,
|
||||
sender_id: Some(matrix_message.sender_id.clone()),
|
||||
content: MessageContent::Text(text.clone()),
|
||||
timestamp: matrix_message.timestamp.to_owned(),
|
||||
}),
|
||||
MatrixMessageContent::Image(image_content, mime_type, media_bytes) => Some(Message {
|
||||
author: Author::User,
|
||||
sender_id: Some(matrix_message.sender_id.clone()),
|
||||
content: MessageContent::Image(ImageDetails::new(
|
||||
image_content.clone(),
|
||||
mime_type.clone(),
|
||||
@@ -90,5 +110,15 @@ fn convert_user_message(matrix_message: &MatrixMessage) -> Option<Message> {
|
||||
)),
|
||||
timestamp: matrix_message.timestamp.to_owned(),
|
||||
}),
|
||||
MatrixMessageContent::File(file_content, mime_type, media_bytes) => Some(Message {
|
||||
author: Author::User,
|
||||
sender_id: Some(matrix_message.sender_id.clone()),
|
||||
content: MessageContent::File(FileDetails::new(
|
||||
file_content.clone(),
|
||||
mime_type.clone(),
|
||||
media_bytes.clone(),
|
||||
)),
|
||||
timestamp: matrix_message.timestamp.to_owned(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ use chrono::{DateTime, Utc};
|
||||
use regex::Regex;
|
||||
|
||||
use mxlink::matrix_sdk::ruma::OwnedUserId;
|
||||
use mxlink::matrix_sdk::ruma::events::room::message::ImageMessageEventContent;
|
||||
use mxlink::matrix_sdk::ruma::events::room::message::{
|
||||
FileMessageEventContent, ImageMessageEventContent,
|
||||
};
|
||||
use mxlink::mime::Mime;
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -18,6 +20,7 @@ pub enum MatrixMessageContent {
|
||||
Text(String),
|
||||
Notice(String),
|
||||
Image(ImageMessageEventContent, Mime, Vec<u8>),
|
||||
File(FileMessageEventContent, Mime, Vec<u8>),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
|
||||
@@ -119,7 +119,7 @@ async fn get_matrix_messages_in_reply_chain_native(
|
||||
AnySyncMessageLikeEvent::RoomMessage(room_message) => {
|
||||
if let SyncMessageLikeEvent::Original(room_message_original) = room_message {
|
||||
match room_message_original.content.relates_to {
|
||||
Some(Relation::Reply { in_reply_to }) => Some(in_reply_to.event_id.clone()),
|
||||
Some(Relation::Reply(reply)) => Some(reply.in_reply_to.event_id.clone()),
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
@@ -154,35 +154,35 @@ pub async fn process_matrix_messages(
|
||||
|
||||
let mut message = message.clone();
|
||||
|
||||
if i == 0 && !params.first_message_prefixes_to_strip.is_empty() {
|
||||
if let MatrixMessageContent::Text(message_text) = &message.content {
|
||||
let mut message_text = message_text.clone();
|
||||
if i == 0
|
||||
&& !params.first_message_prefixes_to_strip.is_empty()
|
||||
&& let MatrixMessageContent::Text(message_text) = &message.content
|
||||
{
|
||||
let mut message_text = message_text.clone();
|
||||
|
||||
for prefix in ¶ms.first_message_prefixes_to_strip {
|
||||
if let Some(message_text_stripped) = message_text.strip_prefix(prefix) {
|
||||
message_text = message_text_stripped.to_owned();
|
||||
}
|
||||
for prefix in ¶ms.first_message_prefixes_to_strip {
|
||||
if let Some(message_text_stripped) = message_text.strip_prefix(prefix) {
|
||||
message_text = message_text_stripped.to_owned();
|
||||
}
|
||||
|
||||
message.content = MatrixMessageContent::Text(message_text.trim().to_owned());
|
||||
}
|
||||
|
||||
message.content = MatrixMessageContent::Text(message_text.trim().to_owned());
|
||||
}
|
||||
|
||||
// We only strip `bot_user_prefixes_to_strip`-defined prefixes from messages that mention the bot user.
|
||||
if !params.bot_user_prefixes_to_strip.is_empty()
|
||||
&& message.mentioned_users.contains(¶ms.bot_user_id)
|
||||
&& let MatrixMessageContent::Text(message_text) = &message.content
|
||||
{
|
||||
if let MatrixMessageContent::Text(message_text) = &message.content {
|
||||
let mut message_text = message_text.clone();
|
||||
let mut message_text = message_text.clone();
|
||||
|
||||
for prefix in ¶ms.bot_user_prefixes_to_strip {
|
||||
if let Some(message_text_stripped) = message_text.strip_prefix(prefix) {
|
||||
message_text = message_text_stripped.to_owned();
|
||||
}
|
||||
for prefix in ¶ms.bot_user_prefixes_to_strip {
|
||||
if let Some(message_text_stripped) = message_text.strip_prefix(prefix) {
|
||||
message_text = message_text_stripped.to_owned();
|
||||
}
|
||||
|
||||
message.content = MatrixMessageContent::Text(message_text.trim().to_owned());
|
||||
}
|
||||
|
||||
message.content = MatrixMessageContent::Text(message_text.trim().to_owned());
|
||||
}
|
||||
|
||||
messages_filtered.push(message);
|
||||
@@ -234,6 +234,7 @@ pub async fn convert_matrix_native_event_to_matrix_message(
|
||||
MessageType::Text(text_content) => (text_content.body.clone(), false),
|
||||
MessageType::Notice(notice_content) => (notice_content.body.clone(), true),
|
||||
MessageType::Image(image_content) => (image_content.body.clone(), false),
|
||||
MessageType::File(file_content) => (file_content.body.clone(), false),
|
||||
_ => return Ok(None),
|
||||
};
|
||||
|
||||
@@ -291,6 +292,67 @@ pub async fn convert_matrix_native_event_to_matrix_message(
|
||||
}));
|
||||
}
|
||||
|
||||
if let MessageType::File(file_content) = &room_message.msgtype {
|
||||
let media_request = mxlink::matrix_sdk::media::MediaRequestParameters {
|
||||
source: file_content.source.to_owned(),
|
||||
format: mxlink::matrix_sdk::media::MediaFormat::File,
|
||||
};
|
||||
|
||||
let file_name = file_content
|
||||
.filename
|
||||
.clone()
|
||||
.unwrap_or(file_content.body.clone());
|
||||
|
||||
let mime_type = file_content
|
||||
.info
|
||||
.as_ref()
|
||||
.and_then(|info| info.mimetype.clone())
|
||||
.and_then(|mimetype| mimetype.parse::<mxlink::mime::Mime>().ok())
|
||||
.unwrap_or_else(|| get_mime_type_from_file_name(&file_name));
|
||||
|
||||
tracing::debug!("Determined mime type {} for file {}", mime_type, file_name);
|
||||
|
||||
if mime_type == mxlink::mime::APPLICATION_OCTET_STREAM {
|
||||
tracing::debug!(
|
||||
"Skipping file {} with unsupported MIME type {}. It will be represented as a text message.",
|
||||
file_name,
|
||||
mime_type,
|
||||
);
|
||||
|
||||
return Ok(Some(MatrixMessage {
|
||||
sender_id: matrix_native_event.sender().to_owned(),
|
||||
content: MatrixMessageContent::Text(format!(
|
||||
"[A file ({}) was attached but skipped because its content type ({}) is not supported. Let the user know.]",
|
||||
file_name, mime_type,
|
||||
)),
|
||||
mentioned_users,
|
||||
timestamp,
|
||||
}));
|
||||
}
|
||||
|
||||
let span = tracing::debug_span!("get_media_content", file_name = %file_name, mime_type = %mime_type);
|
||||
|
||||
let media_bytes = matrix_link
|
||||
.client()
|
||||
.media()
|
||||
.get_media_content(&media_request, true)
|
||||
.instrument(span)
|
||||
.await?;
|
||||
|
||||
tracing::debug!(
|
||||
"Downloaded {} bytes for file {}",
|
||||
media_bytes.len(),
|
||||
file_name
|
||||
);
|
||||
|
||||
return Ok(Some(MatrixMessage {
|
||||
sender_id: matrix_native_event.sender().to_owned(),
|
||||
content: MatrixMessageContent::File(file_content.clone(), mime_type, media_bytes),
|
||||
mentioned_users,
|
||||
timestamp,
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(Some(MatrixMessage {
|
||||
sender_id: matrix_native_event.sender().to_owned(),
|
||||
content: if is_notice {
|
||||
@@ -358,11 +420,11 @@ pub async fn determine_interaction_context_for_room_event(
|
||||
)
|
||||
.await
|
||||
}
|
||||
Relation::Reply { in_reply_to } => {
|
||||
Relation::Reply(reply) => {
|
||||
determine_interaction_context_for_room_event_related_to_reply(
|
||||
current_event,
|
||||
current_event_is_mentioning_bot,
|
||||
in_reply_to.event_id.clone(),
|
||||
reply.in_reply_to.event_id.clone(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use mxlink::helpers::encryption::EncryptionKey;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use mxlink::matrix_sdk::ruma::{OwnedDeviceId, OwnedUserId};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
|
||||
use crate::{
|
||||
agent::{AgentDefinition, AgentPurpose, PublicIdentifier},
|
||||
@@ -38,7 +39,7 @@ pub struct Config {
|
||||
impl Config {
|
||||
pub fn validate(&self) -> anyhow::Result<()> {
|
||||
self.homeserver.validate()?;
|
||||
self.user.validate()?;
|
||||
self.user.validate(&self.homeserver.server_name)?;
|
||||
self.persistence.validate()?;
|
||||
self.room.validate()?;
|
||||
self.access.validate()?;
|
||||
@@ -57,6 +58,19 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ConfigUserAuth {
|
||||
UserPassword {
|
||||
username: String,
|
||||
password: String,
|
||||
},
|
||||
AccessToken {
|
||||
user_id: OwnedUserId,
|
||||
device_id: OwnedDeviceId,
|
||||
access_token: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ConfigHomeserver {
|
||||
pub server_name: String,
|
||||
@@ -83,20 +97,72 @@ impl ConfigHomeserver {
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for the bot's avatar.
|
||||
///
|
||||
/// - `Default`: Use the built-in default avatar (null, empty string, or missing in config)
|
||||
/// - `Keep`: Don't touch the avatar, keep whatever is already set ("keep" in config)
|
||||
/// - `Custom(String)`: Use a custom avatar from the specified file path
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
|
||||
pub enum Avatar {
|
||||
/// Use the built-in default avatar
|
||||
#[default]
|
||||
Default,
|
||||
/// Keep the current avatar, don't change it
|
||||
Keep,
|
||||
/// Use a custom avatar from the specified file path
|
||||
Custom(String),
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Avatar {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let value: Option<String> = Option::deserialize(deserializer)?;
|
||||
Ok(match value {
|
||||
None => Avatar::Default,
|
||||
Some(s) => Avatar::from_string(s),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Avatar {
|
||||
pub fn from_string(value: String) -> Self {
|
||||
if value.is_empty() {
|
||||
Avatar::Default
|
||||
} else if value.eq_ignore_ascii_case("keep") {
|
||||
Avatar::Keep
|
||||
} else {
|
||||
Avatar::Custom(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ConfigUser {
|
||||
pub mxid_localpart: String,
|
||||
pub password: String,
|
||||
|
||||
#[serde(default)]
|
||||
pub password: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub access_token: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub device_id: Option<String>,
|
||||
|
||||
#[serde(default = "super::defaults::name")]
|
||||
pub name: String,
|
||||
|
||||
#[serde(default)]
|
||||
pub encryption: ConfigUserEncryption,
|
||||
|
||||
#[serde(default)]
|
||||
pub avatar: Avatar,
|
||||
}
|
||||
|
||||
impl ConfigUser {
|
||||
pub fn validate(&self) -> anyhow::Result<()> {
|
||||
pub fn validate(&self, homeserver_server_name: &str) -> anyhow::Result<()> {
|
||||
if self.mxid_localpart.is_empty() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"The user.mxid_localpart ({}) configuration must be set",
|
||||
@@ -104,12 +170,7 @@ impl ConfigUser {
|
||||
));
|
||||
}
|
||||
|
||||
if self.password.is_empty() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"The user.password ({}) configuration must be set",
|
||||
super::env::BAIBOT_USER_PASSWORD
|
||||
));
|
||||
}
|
||||
self.auth_config(homeserver_server_name)?;
|
||||
|
||||
if self.name.is_empty() {
|
||||
return Err(anyhow::anyhow!(
|
||||
@@ -122,6 +183,57 @@ impl ConfigUser {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn auth_config(&self, homeserver_server_name: &str) -> anyhow::Result<ConfigUserAuth> {
|
||||
let password = self.password.as_deref().filter(|value| !value.is_empty());
|
||||
let access_token = self
|
||||
.access_token
|
||||
.as_deref()
|
||||
.filter(|value| !value.is_empty());
|
||||
|
||||
match (password, access_token) {
|
||||
(Some(_), Some(_)) => Err(anyhow::anyhow!(
|
||||
"Set exactly one authentication method: either user.password ({}) OR user.access_token ({}) + user.device_id ({})",
|
||||
super::env::BAIBOT_USER_PASSWORD,
|
||||
super::env::BAIBOT_USER_ACCESS_TOKEN,
|
||||
super::env::BAIBOT_USER_DEVICE_ID
|
||||
)),
|
||||
(None, None) => Err(anyhow::anyhow!(
|
||||
"Set one authentication method: either user.password ({}) OR user.access_token ({}) + user.device_id ({})",
|
||||
super::env::BAIBOT_USER_PASSWORD,
|
||||
super::env::BAIBOT_USER_ACCESS_TOKEN,
|
||||
super::env::BAIBOT_USER_DEVICE_ID
|
||||
)),
|
||||
(Some(password), None) => Ok(ConfigUserAuth::UserPassword {
|
||||
username: self.mxid_localpart.to_owned(),
|
||||
password: password.to_owned(),
|
||||
}),
|
||||
(None, Some(access_token)) => {
|
||||
let device_id = self
|
||||
.device_id
|
||||
.as_deref()
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"user.device_id ({}) must be set when using access token authentication",
|
||||
super::env::BAIBOT_USER_DEVICE_ID
|
||||
)
|
||||
})?;
|
||||
|
||||
let user_id = OwnedUserId::try_from(format!(
|
||||
"@{}:{}",
|
||||
self.mxid_localpart, homeserver_server_name
|
||||
))
|
||||
.map_err(|e| anyhow::anyhow!("Invalid user ID: {e}"))?;
|
||||
|
||||
Ok(ConfigUserAuth::AccessToken {
|
||||
user_id,
|
||||
device_id: OwnedDeviceId::from(device_id),
|
||||
access_token: access_token.to_owned(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
@@ -132,13 +244,13 @@ pub struct ConfigUserEncryption {
|
||||
|
||||
impl ConfigUserEncryption {
|
||||
pub fn validate(&self) -> anyhow::Result<()> {
|
||||
if let Some(passphrase) = &self.recovery_passphrase {
|
||||
if passphrase.is_empty() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"The user.encryption.recovery_passphrase ({}) configuration must either be null or set to a non-empty passphrase",
|
||||
super::env::BAIBOT_USER_ENCRYPTION_RECOVERY_PASSPHRASE
|
||||
));
|
||||
}
|
||||
if let Some(passphrase) = &self.recovery_passphrase
|
||||
&& passphrase.is_empty()
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"The user.encryption.recovery_passphrase ({}) configuration must either be null or set to a non-empty passphrase",
|
||||
super::env::BAIBOT_USER_ENCRYPTION_RECOVERY_PASSPHRASE
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -424,3 +536,7 @@ impl TryInto<GlobalConfig> for ConfigInitialGlobalConfig {
|
||||
Ok(entity)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "config_tests.rs"]
|
||||
mod config_tests;
|
||||
|
||||
117
src/entity/cfg/config_tests.rs
Normal file
117
src/entity/cfg/config_tests.rs
Normal file
@@ -0,0 +1,117 @@
|
||||
use super::{Avatar, ConfigUser, ConfigUserAuth, ConfigUserEncryption};
|
||||
use crate::entity::cfg::env;
|
||||
|
||||
fn base_user() -> ConfigUser {
|
||||
ConfigUser {
|
||||
mxid_localpart: "baibot".to_owned(),
|
||||
password: None,
|
||||
access_token: None,
|
||||
device_id: None,
|
||||
name: "baibot".to_owned(),
|
||||
encryption: ConfigUserEncryption {
|
||||
recovery_passphrase: None,
|
||||
recovery_reset_allowed: false,
|
||||
},
|
||||
avatar: Avatar::Default,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_config_uses_password_mode() {
|
||||
let mut user = base_user();
|
||||
user.password = Some("secret".to_owned());
|
||||
|
||||
let auth = user
|
||||
.auth_config("example.com")
|
||||
.expect("password auth should be valid");
|
||||
|
||||
match auth {
|
||||
ConfigUserAuth::UserPassword { username, password } => {
|
||||
assert_eq!(username, "baibot");
|
||||
assert_eq!(password, "secret");
|
||||
}
|
||||
ConfigUserAuth::AccessToken { .. } => {
|
||||
panic!("expected password auth mode");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_config_uses_access_token_mode() {
|
||||
let mut user = base_user();
|
||||
user.access_token = Some("token123".to_owned());
|
||||
user.device_id = Some("DEVICE1".to_owned());
|
||||
|
||||
let auth = user
|
||||
.auth_config("example.com")
|
||||
.expect("access token auth should be valid");
|
||||
|
||||
match auth {
|
||||
ConfigUserAuth::AccessToken {
|
||||
user_id,
|
||||
device_id,
|
||||
access_token,
|
||||
} => {
|
||||
assert_eq!(user_id.as_str(), "@baibot:example.com");
|
||||
assert_eq!(device_id.as_str(), "DEVICE1");
|
||||
assert_eq!(access_token, "token123");
|
||||
}
|
||||
ConfigUserAuth::UserPassword { .. } => {
|
||||
panic!("expected access token auth mode");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_config_rejects_both_auth_methods() {
|
||||
let mut user = base_user();
|
||||
user.password = Some("secret".to_owned());
|
||||
user.access_token = Some("token123".to_owned());
|
||||
user.device_id = Some("DEVICE1".to_owned());
|
||||
|
||||
let err = user
|
||||
.auth_config("example.com")
|
||||
.expect_err("both auth methods should be rejected");
|
||||
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("exactly one authentication method")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_config_rejects_missing_auth() {
|
||||
let user = base_user();
|
||||
|
||||
let err = user
|
||||
.auth_config("example.com")
|
||||
.expect_err("missing auth should be rejected");
|
||||
|
||||
assert!(err.to_string().contains("Set one authentication method"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_config_rejects_access_token_without_device_id() {
|
||||
let mut user = base_user();
|
||||
user.access_token = Some("token123".to_owned());
|
||||
|
||||
let err = user
|
||||
.auth_config("example.com")
|
||||
.expect_err("access token mode without device_id should be rejected");
|
||||
|
||||
assert!(err.to_string().contains(env::BAIBOT_USER_DEVICE_ID));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_config_treats_empty_strings_as_unset() {
|
||||
let mut user = base_user();
|
||||
user.password = Some(String::new());
|
||||
user.access_token = Some(String::new());
|
||||
user.device_id = Some(String::new());
|
||||
|
||||
let err = user
|
||||
.auth_config("example.com")
|
||||
.expect_err("empty auth values should be treated as unset");
|
||||
|
||||
assert!(err.to_string().contains("Set one authentication method"));
|
||||
}
|
||||
@@ -5,9 +5,14 @@ pub const BAIBOT_HOMESERVER_URL: &str = "BAIBOT_HOMESERVER_URL";
|
||||
|
||||
pub const BAIBOT_USER_MXID_LOCALPART: &str = "BAIBOT_USER_MXID_LOCALPART";
|
||||
pub const BAIBOT_USER_PASSWORD: &str = "BAIBOT_USER_PASSWORD";
|
||||
pub const BAIBOT_USER_ACCESS_TOKEN: &str = "BAIBOT_USER_ACCESS_TOKEN";
|
||||
pub const BAIBOT_USER_DEVICE_ID: &str = "BAIBOT_USER_DEVICE_ID";
|
||||
pub const BAIBOT_USER_NAME: &str = "BAIBOT_USER_NAME";
|
||||
pub const BAIBOT_USER_AVATAR: &str = "BAIBOT_USER_AVATAR";
|
||||
pub const BAIBOT_USER_ENCRYPTION_RECOVERY_PASSPHRASE: &str =
|
||||
"BAIBOT_USER_ENCRYPTION_RECOVERY_PASSPHRASE";
|
||||
pub const BAIBOT_USER_ENCRYPTION_RECOVERY_RESET_ALLOWED: &str =
|
||||
"BAIBOT_USER_ENCRYPTION_RECOVERY_RESET_ALLOWED";
|
||||
|
||||
pub const BAIBOT_COMMAND_PREFIX: &str = "BAIBOT_COMMAND_PREFIX";
|
||||
|
||||
|
||||
@@ -2,4 +2,4 @@ mod config;
|
||||
pub mod defaults;
|
||||
pub mod env;
|
||||
|
||||
pub use config::Config;
|
||||
pub use config::{Avatar, Config, ConfigUserAuth};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use mxlink::matrix_sdk::ruma::events::room::message::{
|
||||
AudioMessageEventContent, ImageMessageEventContent, MessageType, TextMessageEventContent,
|
||||
AudioMessageEventContent, FileMessageEventContent, ImageMessageEventContent, MessageType,
|
||||
TextMessageEventContent,
|
||||
};
|
||||
use mxlink::matrix_sdk::ruma::{OwnedEventId, OwnedUserId};
|
||||
|
||||
@@ -30,6 +31,7 @@ pub enum MessagePayload {
|
||||
Text(TextMessageEventContent),
|
||||
Audio(AudioMessageEventContent),
|
||||
Image(ImageMessageEventContent),
|
||||
File(FileMessageEventContent),
|
||||
|
||||
Reaction {
|
||||
key: String,
|
||||
@@ -57,6 +59,7 @@ impl TryInto<MessagePayload> for MessageType {
|
||||
MessagePayload::Audio(audio_content)
|
||||
}
|
||||
MessageType::Image(image_content) => MessagePayload::Image(image_content),
|
||||
MessageType::File(file_content) => MessagePayload::File(file_content),
|
||||
other => {
|
||||
return Err(format!("Unsupported message type: {:?}", other));
|
||||
}
|
||||
|
||||
@@ -5,8 +5,9 @@ use super::roomconfig::RoomConfig;
|
||||
|
||||
use crate::entity::roomconfig::{
|
||||
SpeechToTextFlowType, SpeechToTextMessageTypeForNonThreadedOnlyTranscribedMessages,
|
||||
TextGenerationAutoUsage, TextGenerationPrefixRequirementType, TextToSpeechBotMessagesFlowType,
|
||||
TextToSpeechUserMessagesFlowType, defaults as roomconfig_defaults,
|
||||
TextGenerationAutoUsage, TextGenerationPrefixRequirementType, TextGenerationSenderContextMode,
|
||||
TextToSpeechBotMessagesFlowType, TextToSpeechUserMessagesFlowType,
|
||||
defaults as roomconfig_defaults,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -135,6 +136,20 @@ impl RoomConfigContext {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn text_generation_sender_context_mode(&self) -> TextGenerationSenderContextMode {
|
||||
self.room_config
|
||||
.settings
|
||||
.text_generation
|
||||
.sender_context_mode
|
||||
.or({
|
||||
self.global_config
|
||||
.fallback_room_settings
|
||||
.text_generation
|
||||
.sender_context_mode
|
||||
})
|
||||
.unwrap_or(roomconfig_defaults::TEXT_GENERATION_SENDER_CONTEXT_MODE)
|
||||
}
|
||||
|
||||
pub fn text_generation_prefix_requirement_type(&self) -> TextGenerationPrefixRequirementType {
|
||||
self.room_config
|
||||
.settings
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use super::{SpeechToTextFlowType, SpeechToTextMessageTypeForNonThreadedOnlyTranscribedMessages};
|
||||
use super::{TextGenerationAutoUsage, TextGenerationPrefixRequirementType};
|
||||
use super::{
|
||||
TextGenerationAutoUsage, TextGenerationPrefixRequirementType, TextGenerationSenderContextMode,
|
||||
};
|
||||
use super::{TextToSpeechBotMessagesFlowType, TextToSpeechUserMessagesFlowType};
|
||||
|
||||
pub const TEXT_GENERATION_PREFIX_REQUIREMENT_TYPE: TextGenerationPrefixRequirementType =
|
||||
@@ -7,6 +9,9 @@ pub const TEXT_GENERATION_PREFIX_REQUIREMENT_TYPE: TextGenerationPrefixRequireme
|
||||
|
||||
pub const TEXT_GENERATION_AUTO_USAGE: TextGenerationAutoUsage = TextGenerationAutoUsage::Always;
|
||||
|
||||
pub const TEXT_GENERATION_SENDER_CONTEXT_MODE: TextGenerationSenderContextMode =
|
||||
TextGenerationSenderContextMode::Disabled;
|
||||
|
||||
pub const TEXT_TO_SPEECH_BOT_MESSAGES_FLOW_TYPE: TextToSpeechBotMessagesFlowType =
|
||||
TextToSpeechBotMessagesFlowType::OnDemandForVoice;
|
||||
|
||||
|
||||
@@ -16,7 +16,9 @@ pub use handler::RoomSettingsHandler;
|
||||
pub use speech_to_text::{
|
||||
SpeechToTextFlowType, SpeechToTextMessageTypeForNonThreadedOnlyTranscribedMessages,
|
||||
};
|
||||
pub use text_generation::{TextGenerationAutoUsage, TextGenerationPrefixRequirementType};
|
||||
pub use text_generation::{
|
||||
TextGenerationAutoUsage, TextGenerationPrefixRequirementType, TextGenerationSenderContextMode,
|
||||
};
|
||||
pub use text_to_speech::{TextToSpeechBotMessagesFlowType, TextToSpeechUserMessagesFlowType};
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, EventContent)]
|
||||
|
||||
@@ -14,6 +14,9 @@ pub struct RoomSettingsTextGeneration {
|
||||
/// When enabled, the bot will automatically tokenize messages and try to shorten the message context intelligently.
|
||||
pub context_management_enabled: Option<bool>,
|
||||
|
||||
/// Controls how each message in the conversation context is annotated with sender metadata.
|
||||
pub sender_context_mode: Option<TextGenerationSenderContextMode>,
|
||||
|
||||
/// Allows customizing the system prompt that the agent would use
|
||||
pub prompt_override: Option<String>,
|
||||
|
||||
@@ -111,3 +114,46 @@ impl std::fmt::Display for TextGenerationAutoUsage {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq)]
|
||||
pub enum TextGenerationSenderContextMode {
|
||||
#[serde(rename = "disabled")]
|
||||
Disabled,
|
||||
|
||||
#[serde(rename = "matrix_user_id")]
|
||||
MatrixUserId,
|
||||
|
||||
#[serde(rename = "matrix_user_id_and_timestamp")]
|
||||
MatrixUserIdAndTimestamp,
|
||||
}
|
||||
|
||||
impl TextGenerationSenderContextMode {
|
||||
pub fn choices() -> Vec<Self> {
|
||||
vec![
|
||||
Self::Disabled,
|
||||
Self::MatrixUserId,
|
||||
Self::MatrixUserIdAndTimestamp,
|
||||
]
|
||||
}
|
||||
|
||||
pub fn from_str(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"disabled" => Some(Self::Disabled),
|
||||
"matrix_user_id" => Some(Self::MatrixUserId),
|
||||
"matrix_user_id_and_timestamp" => Some(Self::MatrixUserIdAndTimestamp),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for TextGenerationSenderContextMode {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
TextGenerationSenderContextMode::Disabled => write!(f, "disabled"),
|
||||
TextGenerationSenderContextMode::MatrixUserId => write!(f, "matrix_user_id"),
|
||||
TextGenerationSenderContextMode::MatrixUserIdAndTimestamp => {
|
||||
write!(f, "matrix_user_id_and_timestamp")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ use mxlink::helpers::account_data_config::RoomConfigManager as AccountDataRoomCo
|
||||
pub use entity::{RoomConfig, RoomConfigCarrierContent, RoomSettings, RoomSettingsHandler};
|
||||
pub use entity::{
|
||||
SpeechToTextFlowType, SpeechToTextMessageTypeForNonThreadedOnlyTranscribedMessages,
|
||||
TextGenerationAutoUsage, TextGenerationPrefixRequirementType, TextToSpeechBotMessagesFlowType,
|
||||
TextToSpeechUserMessagesFlowType,
|
||||
TextGenerationAutoUsage, TextGenerationPrefixRequirementType, TextGenerationSenderContextMode,
|
||||
TextToSpeechBotMessagesFlowType, TextToSpeechUserMessagesFlowType,
|
||||
};
|
||||
|
||||
pub type RoomConfigurationManager =
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
// rustc 1.94+ trips a query-depth overflow when computing async layouts in
|
||||
// the matrix-sdk timeline future graph. matrix-rust-sdk PR #6489 raises the
|
||||
// limit, but `recursion_limit` is per-crate and applies to the crate currently
|
||||
// being compiled — so the consumer has to repeat it.
|
||||
#![recursion_limit = "256"]
|
||||
|
||||
mod agent;
|
||||
mod bot;
|
||||
mod controller;
|
||||
|
||||
@@ -249,6 +249,13 @@ pub fn status_text_generation_entry_context_management(value: bool, set_where: &
|
||||
format!("- ♻️ Context management: `{}` ({})\n", value, set_where)
|
||||
}
|
||||
|
||||
pub fn status_text_generation_entry_sender_context(
|
||||
value: impl std::fmt::Display,
|
||||
set_where: &str,
|
||||
) -> String {
|
||||
format!("- 👤 Sender context mode: `{}` ({})\n", value, set_where)
|
||||
}
|
||||
|
||||
pub fn status_text_generation_entry_prompt(value: &str, set_where: &str) -> String {
|
||||
let value = value.trim();
|
||||
|
||||
|
||||
@@ -132,6 +132,18 @@ pub fn text_generation_context_management_intro() -> String {
|
||||
)
|
||||
}
|
||||
|
||||
pub fn text_generation_sender_context_heading() -> &'static str {
|
||||
"👤 Sender Context Mode"
|
||||
}
|
||||
|
||||
pub fn text_generation_sender_context_intro() -> String {
|
||||
format!(
|
||||
"{}\n{}",
|
||||
"Controls whether the bot attaches sender information to conversation messages before sending them to the model.",
|
||||
"`disabled` leaves messages unchanged, `matrix_user_id` adds `[sender=@alice:example.com]`, and `matrix_user_id_and_timestamp` adds `[sender=@alice:example.com sent_at=2026-03-23T14:30:00Z]`. Enabling this sends Matrix user IDs, and optionally timestamps, to the model provider.",
|
||||
)
|
||||
}
|
||||
|
||||
pub fn text_generation_prompt_override_heading() -> &'static str {
|
||||
"⌨️ Prompt Override"
|
||||
}
|
||||
|
||||
@@ -109,11 +109,21 @@ pub fn help_provider_details(id: &str, info: &AgentProviderInfo) -> String {
|
||||
let mut purpose_line = format!("{} {}", purpose.emoji(), purpose.as_str());
|
||||
|
||||
if let AgentPurpose::TextGeneration = purpose {
|
||||
let mut extras = vec![];
|
||||
|
||||
if info.text_generation_supports_vision {
|
||||
purpose_line = format!("{} ({})", purpose_line, "incl. vision");
|
||||
extras.push("incl. vision");
|
||||
} else {
|
||||
purpose_line = format!("{} ({})", purpose_line, "no vision");
|
||||
extras.push("no vision");
|
||||
}
|
||||
|
||||
if info.text_generation_supports_tools {
|
||||
extras.push("incl. tools");
|
||||
} else {
|
||||
extras.push("no tools");
|
||||
}
|
||||
|
||||
purpose_line = format!("{} ({})", purpose_line, extras.join(", "));
|
||||
}
|
||||
|
||||
capabilities.push(purpose_line);
|
||||
|
||||
@@ -64,7 +64,7 @@ To create a sticker, send a command like `%command_prefix% sticker A huge bowl o
|
||||
|
||||
The difference from **creating images** is that the bot will:
|
||||
|
||||
- create a smaller-resolution image (`256x256`) - smaller/quicker, but still good enough for a sticker
|
||||
- create a smaller-resolution image (as small as the model allows) - smaller/quicker, but still good enough for a sticker
|
||||
- potentially switch to a different (cheaper or otherwise more suitable) model, if available
|
||||
- post the image directly to the room (as a reply to your message), without starting a threaded conversation
|
||||
"#;
|
||||
|
||||
@@ -17,18 +17,7 @@ pub fn get_file_extension(mime_type: &mime::Mime) -> String {
|
||||
}
|
||||
|
||||
pub fn get_mime_type_from_file_name(file_name: &str) -> mime::Mime {
|
||||
let extension = file_name.rsplit('.').next().unwrap_or("");
|
||||
|
||||
match extension.to_lowercase().as_str() {
|
||||
"jpg" | "jpeg" => mime::IMAGE_JPEG,
|
||||
"png" => mime::IMAGE_PNG,
|
||||
"gif" => mime::IMAGE_GIF,
|
||||
"webp" => "image/webp".parse().unwrap(),
|
||||
"svg" => mime::IMAGE_SVG,
|
||||
"tiff" | "tif" => "image/tiff".parse().unwrap(),
|
||||
"bmp" => "image/bmp".parse().unwrap(),
|
||||
"heic" | "heif" => "image/heic".parse().unwrap(),
|
||||
"avif" => "image/avif".parse().unwrap(),
|
||||
_ => mime::APPLICATION_OCTET_STREAM,
|
||||
}
|
||||
mime_guess::from_path(file_name)
|
||||
.first()
|
||||
.unwrap_or(mime::APPLICATION_OCTET_STREAM)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user