mirror of
https://github.com/zadam/trilium.git
synced 2026-02-10 15:54:27 +01:00
Merge bb2a08091675d29d14f32a47afb043a6d0128414 into bbf090edf017e4040d09faeb9a208f9903f6fd40
This commit is contained in:
commit
f0701e4d5d
4
.github/workflows/checks.yml
vendored
4
.github/workflows/checks.yml
vendored
@ -12,7 +12,7 @@ jobs:
|
||||
steps:
|
||||
- name: Check if PRs have conflicts
|
||||
uses: eps1lon/actions-label-merge-conflict@v3
|
||||
if: github.repository == ${{ vars.REPO_MAIN }}
|
||||
if: ${{ vars.REPO_MAIN == '' || github.repository == vars.REPO_MAIN }}
|
||||
with:
|
||||
dirtyLabel: "merge-conflicts"
|
||||
repoToken: "${{ secrets.MERGE_CONFLICT_LABEL_PAT }}"
|
||||
repoToken: ${{ secrets.MERGE_CONFLICT_LABEL_PAT || secrets.GITHUB_TOKEN }}
|
||||
|
||||
234
README.md
234
README.md
@ -1,10 +1,10 @@
|
||||
<div align="center">
|
||||
<sup>Special thanks to:</sup><br />
|
||||
<sup>特别感谢:</sup><br />
|
||||
<a href="https://go.warp.dev/Trilium" target="_blank">
|
||||
<img alt="Warp sponsorship" width="400" src="https://github.com/warpdotdev/brand-assets/blob/main/Github/Sponsor/Warp-Github-LG-03.png"><br />
|
||||
Warp, built for coding with multiple AI agents<br />
|
||||
Warp,为多 AI 智能体编程而生<br />
|
||||
</a>
|
||||
<sup>Available for macOS, Linux and Windows</sup>
|
||||
<sup>适用于 macOS、Linux 和 Windows</sup>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
@ -21,133 +21,139 @@
|
||||
[Chinese (Simplified Han script)](./docs/README-ZH_CN.md) | [Chinese (Traditional Han script)](./docs/README-ZH_TW.md) | [English](./docs/README.md) | [French](./docs/README-fr.md) | [German](./docs/README-de.md) | [Greek](./docs/README-el.md) | [Italian](./docs/README-it.md) | [Japanese](./docs/README-ja.md) | [Romanian](./docs/README-ro.md) | [Spanish](./docs/README-es.md)
|
||||
<!-- translate:on -->
|
||||
|
||||
Trilium Notes is a free and open-source, cross-platform hierarchical note taking application with focus on building large personal knowledge bases.
|
||||
Trilium Notes 是一款免费、开源、跨平台的分层笔记应用,专注于构建大型个人知识库,并内置 AI 助手能力。
|
||||
|
||||
<img src="./docs/app.png" alt="Trilium Screenshot" width="1000">
|
||||
|
||||
## ⏬ Download
|
||||
- [Latest release](https://github.com/TriliumNext/Trilium/releases/latest) – stable version, recommended for most users.
|
||||
- [Nightly build](https://github.com/TriliumNext/Trilium/releases/tag/nightly) – unstable development version, updated daily with the latest features and fixes.
|
||||
## 🤖 AI 与 MiniMax 支持
|
||||
|
||||
## 📚 Documentation
|
||||
- 内置 AI 助手,支持 MiniMax(Anthropic 兼容接口)。
|
||||
- 支持工具调用:搜索、创建、更新、移动、总结笔记。
|
||||
- 可在设置中配置模型与密钥,按需启用 AI 能力。
|
||||
|
||||
**Visit our comprehensive documentation at [docs.triliumnotes.org](https://docs.triliumnotes.org/)**
|
||||
## ⏬ 下载
|
||||
|
||||
Our documentation is available in multiple formats:
|
||||
- **Online Documentation**: Browse the full documentation at [docs.triliumnotes.org](https://docs.triliumnotes.org/)
|
||||
- **In-App Help**: Press `F1` within Trilium to access the same documentation directly in the application
|
||||
- **GitHub**: Navigate through the [User Guide](./docs/User%20Guide/User%20Guide/) in this repository
|
||||
- [最新版本](https://github.com/TriliumNext/Trilium/releases/latest) – 稳定版,推荐大多数用户使用。
|
||||
- [Nightly 构建](https://github.com/TriliumNext/Trilium/releases/tag/nightly) – 不稳定开发版,每日更新最新功能与修复。
|
||||
|
||||
### Quick Links
|
||||
- [Getting Started Guide](https://docs.triliumnotes.org/)
|
||||
- [Installation Instructions](https://docs.triliumnotes.org/user-guide/setup)
|
||||
- [Docker Setup](https://docs.triliumnotes.org/user-guide/setup/server/installation/docker)
|
||||
- [Upgrading TriliumNext](https://docs.triliumnotes.org/user-guide/setup/upgrading)
|
||||
- [Basic Concepts and Features](https://docs.triliumnotes.org/user-guide/concepts/notes)
|
||||
- [Patterns of Personal Knowledge Base](https://docs.triliumnotes.org/user-guide/misc/patterns-of-personal-knowledge)
|
||||
## 📚 文档
|
||||
|
||||
## 🎁 Features
|
||||
**访问完整文档:[docs.triliumnotes.org](https://docs.triliumnotes.org/)**
|
||||
|
||||
* Notes can be arranged into arbitrarily deep tree. Single note can be placed into multiple places in the tree (see [cloning](https://docs.triliumnotes.org/user-guide/concepts/notes/cloning))
|
||||
* Rich WYSIWYG note editor including e.g. tables, images and [math](https://docs.triliumnotes.org/user-guide/note-types/text) with markdown [autoformat](https://docs.triliumnotes.org/user-guide/note-types/text/markdown-formatting)
|
||||
* Support for editing [notes with source code](https://docs.triliumnotes.org/user-guide/note-types/code), including syntax highlighting
|
||||
* Fast and easy [navigation between notes](https://docs.triliumnotes.org/user-guide/concepts/navigation/note-navigation), full text search and [note hoisting](https://docs.triliumnotes.org/user-guide/concepts/navigation/note-hoisting)
|
||||
* Seamless [note versioning](https://docs.triliumnotes.org/user-guide/concepts/notes/note-revisions)
|
||||
* Note [attributes](https://docs.triliumnotes.org/user-guide/advanced-usage/attributes) can be used for note organization, querying and advanced [scripting](https://docs.triliumnotes.org/user-guide/scripts)
|
||||
* UI available in English, German, Spanish, French, Romanian, and Chinese (simplified and traditional)
|
||||
* Direct [OpenID and TOTP integration](https://docs.triliumnotes.org/user-guide/setup/server/mfa) for more secure login
|
||||
* [Synchronization](https://docs.triliumnotes.org/user-guide/setup/synchronization) with self-hosted sync server
|
||||
* there are [3rd party services for hosting synchronisation server](https://docs.triliumnotes.org/user-guide/setup/server/cloud-hosting)
|
||||
* [Sharing](https://docs.triliumnotes.org/user-guide/advanced-usage/sharing) (publishing) notes to public internet
|
||||
* Strong [note encryption](https://docs.triliumnotes.org/user-guide/concepts/notes/protected-notes) with per-note granularity
|
||||
* Sketching diagrams, based on [Excalidraw](https://excalidraw.com/) (note type "canvas")
|
||||
* [Relation maps](https://docs.triliumnotes.org/user-guide/note-types/relation-map) and [note/link maps](https://docs.triliumnotes.org/user-guide/note-types/note-map) for visualizing notes and their relations
|
||||
* Mind maps, based on [Mind Elixir](https://docs.mind-elixir.com/)
|
||||
* [Geo maps](https://docs.triliumnotes.org/user-guide/collections/geomap) with location pins and GPX tracks
|
||||
* [Scripting](https://docs.triliumnotes.org/user-guide/scripts) - see [Advanced showcases](https://docs.triliumnotes.org/user-guide/advanced-usage/advanced-showcases)
|
||||
* [REST API](https://docs.triliumnotes.org/user-guide/advanced-usage/etapi) for automation
|
||||
* Scales well in both usability and performance upwards of 100 000 notes
|
||||
* Touch optimized [mobile frontend](https://docs.triliumnotes.org/user-guide/setup/mobile-frontend) for smartphones and tablets
|
||||
* Built-in [dark theme](https://docs.triliumnotes.org/user-guide/concepts/themes), support for user themes
|
||||
* [Evernote](https://docs.triliumnotes.org/user-guide/concepts/import-export/evernote) and [Markdown import & export](https://docs.triliumnotes.org/user-guide/concepts/import-export/markdown)
|
||||
* [Web Clipper](https://docs.triliumnotes.org/user-guide/setup/web-clipper) for easy saving of web content
|
||||
* Customizable UI (sidebar buttons, user-defined widgets, ...)
|
||||
* [Metrics](https://docs.triliumnotes.org/user-guide/advanced-usage/metrics), along with a Grafana Dashboard.
|
||||
文档提供多种形式:
|
||||
- **在线文档**:浏览 [docs.triliumnotes.org](https://docs.triliumnotes.org/)
|
||||
- **应用内帮助**:在 Trilium 中按 `F1`
|
||||
- **GitHub**:查看仓库内的 [用户指南](./docs/User%20Guide/User%20Guide/)
|
||||
|
||||
✨ Check out the following third-party resources/communities for more TriliumNext related goodies:
|
||||
### 快速链接
|
||||
- [快速开始](https://docs.triliumnotes.org/)
|
||||
- [安装说明](https://docs.triliumnotes.org/user-guide/setup)
|
||||
- [Docker 安装](https://docs.triliumnotes.org/user-guide/setup/server/installation/docker)
|
||||
- [升级 TriliumNext](https://docs.triliumnotes.org/user-guide/setup/upgrading)
|
||||
- [基础概念与特性](https://docs.triliumnotes.org/user-guide/concepts/notes)
|
||||
- [个人知识库的组织方式](https://docs.triliumnotes.org/user-guide/misc/patterns-of-personal-knowledge)
|
||||
|
||||
- [awesome-trilium](https://github.com/Nriver/awesome-trilium) for 3rd party themes, scripts, plugins and more.
|
||||
- [TriliumRocks!](https://trilium.rocks/) for tutorials, guides, and much more.
|
||||
## 🎁 功能
|
||||
|
||||
## ❓Why TriliumNext?
|
||||
* 笔记可组织为任意深度的树结构,单个笔记可在树中多处出现(参见 [cloning](https://docs.triliumnotes.org/user-guide/concepts/notes/cloning))
|
||||
* 富文本 WYSIWYG 编辑器,支持表格、图片、[数学公式](https://docs.triliumnotes.org/user-guide/note-types/text),并带有 Markdown [自动格式化](https://docs.triliumnotes.org/user-guide/note-types/text/markdown-formatting)
|
||||
* 支持编辑 [代码笔记](https://docs.triliumnotes.org/user-guide/note-types/code),包含语法高亮
|
||||
* 快速 [笔记导航](https://docs.triliumnotes.org/user-guide/concepts/navigation/note-navigation)、全文检索与 [笔记提升](https://docs.triliumnotes.org/user-guide/concepts/navigation/note-hoisting)
|
||||
* 无缝的 [笔记版本管理](https://docs.triliumnotes.org/user-guide/concepts/notes/note-revisions)
|
||||
* 通过 [属性](https://docs.triliumnotes.org/user-guide/advanced-usage/attributes) 实现组织、查询与高级 [脚本](https://docs.triliumnotes.org/user-guide/scripts)
|
||||
* UI 提供英文、德文、西班牙文、法文、罗马尼亚文与中文(简体/繁体)
|
||||
* 内置 [OpenID 与 TOTP](https://docs.triliumnotes.org/user-guide/setup/server/mfa) 以提升登录安全性
|
||||
* 与自托管同步服务器进行 [同步](https://docs.triliumnotes.org/user-guide/setup/synchronization)
|
||||
* 提供 [第三方托管服务](https://docs.triliumnotes.org/user-guide/setup/server/cloud-hosting)
|
||||
* 通过 [共享/发布](https://docs.triliumnotes.org/user-guide/advanced-usage/sharing) 将笔记公开到互联网
|
||||
* 强大的 [笔记加密](https://docs.triliumnotes.org/user-guide/concepts/notes/protected-notes),支持单笔记粒度
|
||||
* 基于 [Excalidraw](https://excalidraw.com/) 的草图绘制(笔记类型 "canvas")
|
||||
* [关系图](https://docs.triliumnotes.org/user-guide/note-types/relation-map) 与 [笔记/链接图](https://docs.triliumnotes.org/user-guide/note-types/note-map) 可视化
|
||||
* 基于 [Mind Elixir](https://docs.mind-elixir.com/) 的思维导图
|
||||
* [地理地图](https://docs.triliumnotes.org/user-guide/collections/geomap) 支持位置标记与 GPX 轨迹
|
||||
* [脚本能力](https://docs.triliumnotes.org/user-guide/scripts) – 参见 [高级示例](https://docs.triliumnotes.org/user-guide/advanced-usage/advanced-showcases)
|
||||
* [REST API](https://docs.triliumnotes.org/user-guide/advanced-usage/etapi) 便于自动化
|
||||
* 轻松支持 100,000+ 规模的笔记与良好性能
|
||||
* 适配触控的 [移动端界面](https://docs.triliumnotes.org/user-guide/setup/mobile-frontend)
|
||||
* 内置 [深色主题](https://docs.triliumnotes.org/user-guide/concepts/themes) 并支持用户主题
|
||||
* 支持 [Evernote](https://docs.triliumnotes.org/user-guide/concepts/import-export/evernote) 与 [Markdown 导入/导出](https://docs.triliumnotes.org/user-guide/concepts/import-export/markdown)
|
||||
* [Web Clipper](https://docs.triliumnotes.org/user-guide/setup/web-clipper) 便于保存网页内容
|
||||
* UI 可自定义(侧边栏按钮、用户自定义小组件等)
|
||||
* [Metrics](https://docs.triliumnotes.org/user-guide/advanced-usage/metrics) 指标与 Grafana Dashboard
|
||||
|
||||
The original Trilium developer ([Zadam](https://github.com/zadam)) has graciously given the Trilium repository to the community project which resides at https://github.com/TriliumNext
|
||||
✨ 更多 TriliumNext 社区资源:
|
||||
|
||||
### ⬆️Migrating from Zadam/Trilium?
|
||||
- [awesome-trilium](https://github.com/Nriver/awesome-trilium) – 主题、脚本、插件等合集
|
||||
- [TriliumRocks!](https://trilium.rocks/) – 教程、指南等
|
||||
|
||||
There are no special migration steps to migrate from a zadam/Trilium instance to a TriliumNext/Trilium instance. Simply [install TriliumNext/Trilium](#-installation) as usual and it will use your existing database.
|
||||
## ❓为什么选择 TriliumNext?
|
||||
|
||||
Versions up to and including [v0.90.4](https://github.com/TriliumNext/Trilium/releases/tag/v0.90.4) are compatible with the latest zadam/trilium version of [v0.63.7](https://github.com/zadam/trilium/releases/tag/v0.63.7). Any later versions of TriliumNext/Trilium have their sync versions incremented which prevents direct migration.
|
||||
原作者 [Zadam](https://github.com/zadam) 已将 Trilium 仓库交由社区维护,项目位于 https://github.com/TriliumNext
|
||||
|
||||
## 💬 Discuss with us
|
||||
### ⬆️ 从 Zadam/Trilium 迁移?
|
||||
|
||||
Feel free to join our official conversations. We would love to hear what features, suggestions, or issues you may have!
|
||||
从 zadam/Trilium 迁移到 TriliumNext/Trilium 不需要额外步骤,按常规 [安装 TriliumNext/Trilium](#-安装) 即可继续使用原数据库。
|
||||
|
||||
- [Matrix](https://matrix.to/#/#triliumnext:matrix.org) (For synchronous discussions.)
|
||||
- The `General` Matrix room is also bridged to [XMPP](xmpp:discuss@trilium.thisgreat.party?join)
|
||||
- [Github Discussions](https://github.com/TriliumNext/Trilium/discussions) (For asynchronous discussions.)
|
||||
- [Github Issues](https://github.com/TriliumNext/Trilium/issues) (For bug reports and feature requests.)
|
||||
v0.90.4 及以前版本与 zadam/trilium 的 v0.63.7 兼容;此后的 TriliumNext/Trilium 已提升同步版本号,因此无法直接迁移。
|
||||
|
||||
## 🏗 Installation
|
||||
## 💬 交流与支持
|
||||
|
||||
欢迎加入官方社区讨论:
|
||||
|
||||
- [Matrix](https://matrix.to/#/#triliumnext:matrix.org)(同步交流)
|
||||
- `General` Matrix 房间同样桥接到 [XMPP](xmpp:discuss@trilium.thisgreat.party?join)
|
||||
- [GitHub Discussions](https://github.com/TriliumNext/Trilium/discussions)(异步交流)
|
||||
- [GitHub Issues](https://github.com/TriliumNext/Trilium/issues)(问题反馈与需求)
|
||||
|
||||
## 🏗 安装
|
||||
|
||||
### Windows / MacOS
|
||||
|
||||
Download the binary release for your platform from the [latest release page](https://github.com/TriliumNext/Trilium/releases/latest), unzip the package and run the `trilium` executable.
|
||||
从 [最新版本](https://github.com/TriliumNext/Trilium/releases/latest) 下载对应平台的二进制包,解压后运行 `trilium` 可执行文件。
|
||||
|
||||
### Linux
|
||||
|
||||
If your distribution is listed in the table below, use your distribution's package.
|
||||
如果你的发行版在下表中,请使用发行版提供的包。
|
||||
|
||||
[](https://repology.org/project/triliumnext/versions)
|
||||
|
||||
You may also download the binary release for your platform from the [latest release page](https://github.com/TriliumNext/Trilium/releases/latest), unzip the package and run the `trilium` executable.
|
||||
你也可以从 [最新版本](https://github.com/TriliumNext/Trilium/releases/latest) 下载二进制包,解压后运行 `trilium`。
|
||||
|
||||
TriliumNext is also provided as a Flatpak, but not yet published on FlatHub.
|
||||
TriliumNext 也提供 Flatpak,但尚未发布到 Flathub。
|
||||
|
||||
### Browser (any OS)
|
||||
### 浏览器(任何 OS)
|
||||
|
||||
If you use a server installation (see below), you can directly access the web interface (which is almost identical to the desktop app).
|
||||
如果你使用服务器部署(见下文),可直接访问 Web 界面(几乎与桌面版一致)。
|
||||
|
||||
Currently only the latest versions of Chrome & Firefox are supported (and tested).
|
||||
目前仅支持并测试最新版本的 Chrome 与 Firefox。
|
||||
|
||||
### Mobile
|
||||
### 移动端
|
||||
|
||||
To use TriliumNext on a mobile device, you can use a mobile web browser to access the mobile interface of a server installation (see below).
|
||||
你可以通过移动浏览器访问服务器的移动端界面(见下文)来使用 TriliumNext。
|
||||
|
||||
See issue https://github.com/TriliumNext/Trilium/issues/4962 for more information on mobile app support.
|
||||
移动端原生 App 讨论见:https://github.com/TriliumNext/Trilium/issues/4962
|
||||
|
||||
If you prefer a native Android app, you can use [TriliumDroid](https://apt.izzysoft.de/fdroid/index/apk/eu.fliegendewurst.triliumdroid).
|
||||
Report bugs and missing features at [their repository](https://github.com/FliegendeWurst/TriliumDroid).
|
||||
Note: It is best to disable automatic updates on your server installation (see below) when using TriliumDroid since the sync version must match between Trilium and TriliumDroid.
|
||||
如需 Android 原生客户端,可使用 [TriliumDroid](https://apt.izzysoft.de/fdroid/index/apk/eu.fliegendewurst.triliumdroid)。
|
||||
相关 bug 或缺失功能请在其仓库反馈:[TriliumDroid repository](https://github.com/FliegendeWurst/TriliumDroid)。
|
||||
注意:使用 TriliumDroid 时建议关闭服务器的自动更新(见下文),以保持同步版本一致。
|
||||
|
||||
### Server
|
||||
### 服务器
|
||||
|
||||
To install TriliumNext on your own server (including via Docker from [Dockerhub](https://hub.docker.com/r/triliumnext/trilium)) follow [the server installation docs](https://docs.triliumnotes.org/user-guide/setup/server).
|
||||
在服务器上安装 TriliumNext(含 Docker)请参考:[服务器安装文档](https://docs.triliumnotes.org/user-guide/setup/server)
|
||||
|
||||
## 💻 贡献
|
||||
|
||||
## 💻 Contribute
|
||||
### 翻译
|
||||
|
||||
### Translations
|
||||
如果你是母语使用者,欢迎加入 [Weblate](https://hosted.weblate.org/engage/trilium/) 翻译 Trilium。
|
||||
|
||||
If you are a native speaker, help us translate Trilium by heading over to our [Weblate page](https://hosted.weblate.org/engage/trilium/).
|
||||
|
||||
Here's the language coverage we have so far:
|
||||
当前语言覆盖:
|
||||
|
||||
[](https://hosted.weblate.org/engage/trilium/)
|
||||
|
||||
### Code
|
||||
### 代码
|
||||
|
||||
Download the repository, install dependencies using `pnpm` and then run the server (available at http://localhost:8080):
|
||||
克隆仓库并安装依赖,然后运行服务端(默认 http://localhost:8080):
|
||||
```shell
|
||||
git clone https://github.com/TriliumNext/Trilium.git
|
||||
cd Trilium
|
||||
@ -155,9 +161,9 @@ pnpm install
|
||||
pnpm run server:start
|
||||
```
|
||||
|
||||
### Documentation
|
||||
### 文档
|
||||
|
||||
Download the repository, install dependencies using `pnpm` and then run the environment required to edit the documentation:
|
||||
克隆仓库并安装依赖,然后启动文档编辑环境:
|
||||
```shell
|
||||
git clone https://github.com/TriliumNext/Trilium.git
|
||||
cd Trilium
|
||||
@ -165,19 +171,19 @@ pnpm install
|
||||
pnpm edit-docs:edit-docs
|
||||
```
|
||||
|
||||
Alternatively, if you have Nix installed:
|
||||
如果你安装了 Nix:
|
||||
```shell
|
||||
# Run directly
|
||||
# 直接运行
|
||||
nix run .#edit-docs
|
||||
|
||||
# Or install to your profile
|
||||
# 或安装到 profile
|
||||
nix profile install .#edit-docs
|
||||
trilium-edit-docs
|
||||
```
|
||||
|
||||
### 构建可执行文件
|
||||
|
||||
### Building the Executable
|
||||
Download the repository, install dependencies using `pnpm` and then build the desktop app for Windows:
|
||||
克隆仓库并安装依赖,然后构建 Windows 桌面包:
|
||||
```shell
|
||||
git clone https://github.com/TriliumNext/Trilium.git
|
||||
cd Trilium
|
||||
@ -185,46 +191,16 @@ pnpm install
|
||||
pnpm run --filter desktop electron-forge:make --arch=x64 --platform=win32
|
||||
```
|
||||
|
||||
For more details, see the [development docs](https://github.com/TriliumNext/Trilium/tree/main/docs/Developer%20Guide/Developer%20Guide).
|
||||
更多详情请参考 [开发文档](https://github.com/TriliumNext/Trilium/tree/main/docs/Developer%20Guide/Developer%20Guide)。
|
||||
|
||||
### Developer Documentation
|
||||
### 开发文档
|
||||
|
||||
Please view the [documentation guide](https://github.com/TriliumNext/Trilium/blob/main/docs/Developer%20Guide/Developer%20Guide/Environment%20Setup.md) for details. If you have more questions, feel free to reach out via the links described in the "Discuss with us" section above.
|
||||
请查看 [文档指南](https://github.com/TriliumNext/Trilium/blob/main/docs/Developer%20Guide/Developer%20Guide/Environment%20Setup.md)。如有问题,欢迎通过上文的交流渠道联系我们。
|
||||
|
||||
## 👏 Shoutouts
|
||||
## 👏 致谢
|
||||
|
||||
* [zadam](https://github.com/zadam) for the original concept and implementation of the application.
|
||||
* [Sarah Hussein](https://github.com/Sarah-Hussein) for designing the application icon.
|
||||
* [nriver](https://github.com/nriver) for his work on internationalization.
|
||||
* [Thomas Frei](https://github.com/thfrei) for his original work on the Canvas.
|
||||
* [antoniotejada](https://github.com/nriver) for the original syntax highlight widget.
|
||||
* [Dosu](https://dosu.dev/) for providing us with the automated responses to GitHub issues and discussions.
|
||||
* [Tabler Icons](https://tabler.io/icons) for the system tray icons.
|
||||
|
||||
Trilium would not be possible without the technologies behind it:
|
||||
|
||||
* [CKEditor 5](https://github.com/ckeditor/ckeditor5) - the visual editor behind text notes. We are grateful for being offered a set of the premium features.
|
||||
* [CodeMirror](https://github.com/codemirror/CodeMirror) - code editor with support for huge amount of languages.
|
||||
* [Excalidraw](https://github.com/excalidraw/excalidraw) - the infinite whiteboard used in Canvas notes.
|
||||
* [Mind Elixir](https://github.com/SSShooter/mind-elixir-core) - providing the mind map functionality.
|
||||
* [Leaflet](https://github.com/Leaflet/Leaflet) - for rendering geographical maps.
|
||||
* [Tabulator](https://github.com/olifolkerd/tabulator) - for the interactive table used in collections.
|
||||
* [FancyTree](https://github.com/mar10/fancytree) - feature-rich tree library without real competition.
|
||||
* [jsPlumb](https://github.com/jsplumb/jsplumb) - visual connectivity library. Used in [relation maps](https://docs.triliumnotes.org/user-guide/note-types/relation-map) and [link maps](https://docs.triliumnotes.org/user-guide/advanced-usage/note-map#link-map)
|
||||
|
||||
## 🤝 Support
|
||||
|
||||
Trilium is built and maintained with [hundreds of hours of work](https://github.com/TriliumNext/Trilium/graphs/commit-activity). Your support keeps it open-source, improves features, and covers costs such as hosting.
|
||||
|
||||
Consider supporting the main developer ([eliandoran](https://github.com/eliandoran)) of the application via:
|
||||
|
||||
- [GitHub Sponsors](https://github.com/sponsors/eliandoran)
|
||||
- [PayPal](https://paypal.me/eliandoran)
|
||||
- [Buy Me a Coffee](https://buymeacoffee.com/eliandoran)
|
||||
|
||||
|
||||
## 🔑 License
|
||||
|
||||
Copyright 2017-2025 zadam, Elian Doran, and other contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
||||
* [zadam](https://github.com/zadam) – 原始概念与实现
|
||||
* [Sarah Hussein](https://github.com/Sarah-Hussein) – 应用图标设计
|
||||
* [nriver](https://github.com/nriver) – 国际化贡献
|
||||
* [Thomas Frei](https://github.com/thfrei) – Canvas 初始实现
|
||||
* [antoniotejada](https://github.com/antoniotejada) – 语法高亮小组件初始实现
|
||||
|
||||
@ -1208,6 +1208,7 @@
|
||||
"anthropic_tab": "Anthropic",
|
||||
"voyage_tab": "Voyage AI",
|
||||
"ollama_tab": "Ollama",
|
||||
"minimax_tab": "MiniMax",
|
||||
"enable_ai": "Enable AI/LLM features",
|
||||
"enable_ai_desc": "Enable AI features like note summarization, content generation, and other LLM capabilities",
|
||||
"provider_configuration": "AI Provider Configuration",
|
||||
@ -1233,8 +1234,12 @@
|
||||
"anthropic_model_description": "Anthropic Claude models for chat completion",
|
||||
"voyage_settings": "Voyage AI Settings",
|
||||
"ollama_settings": "Ollama Settings",
|
||||
"minimax_settings": "MiniMax Settings",
|
||||
"ollama_url_description": "URL for the Ollama API (default: http://localhost:11434)",
|
||||
"ollama_model_description": "Ollama model to use for chat completion",
|
||||
"minimax_api_key_description": "Your MiniMax API key",
|
||||
"minimax_url_description": "Base URL for the MiniMax API (default: https://api.minimax.io/anthropic)",
|
||||
"minimax_model_description": "MiniMax model to use for chat completion",
|
||||
"anthropic_configuration": "Anthropic Configuration",
|
||||
"voyage_configuration": "Voyage AI Configuration",
|
||||
"voyage_url_description": "Default: https://api.voyageai.com/v1",
|
||||
@ -1288,7 +1293,8 @@
|
||||
"anthropic": "Anthropic API key is empty. Please enter a valid API key.",
|
||||
"openai": "OpenAI API key is empty. Please enter a valid API key.",
|
||||
"voyage": "Voyage API key is empty. Please enter a valid API key.",
|
||||
"ollama": "Ollama API key is empty. Please enter a valid API key."
|
||||
"ollama": "Ollama API key is empty. Please enter a valid API key.",
|
||||
"minimax": "MiniMax API key is empty. Please enter a valid API key."
|
||||
},
|
||||
"agent": {
|
||||
"processing": "Processing...",
|
||||
|
||||
@ -87,8 +87,10 @@ export async function setupStreamingResponse(
|
||||
}
|
||||
});
|
||||
|
||||
if (!streamResponse || !streamResponse.success) {
|
||||
console.error(`[${responseId}] Failed to initiate streaming`);
|
||||
// Check for explicit success: false, not just falsy
|
||||
// This handles Electron IPC JSON parsing issues where success might be undefined
|
||||
if (!streamResponse || streamResponse.success === false) {
|
||||
console.error(`[${responseId}] Failed to initiate streaming:`, streamResponse);
|
||||
reject(new Error('Failed to initiate streaming'));
|
||||
return;
|
||||
}
|
||||
@ -203,6 +205,10 @@ export async function setupStreamingResponse(
|
||||
if (message.done) {
|
||||
console.log(`[${responseId}] Stream completed for chat note ${noteId}, final response: ${assistantResponse.length} chars`);
|
||||
|
||||
if (!message.content && receivedAnyContent) {
|
||||
onContentUpdate(assistantResponse, true);
|
||||
}
|
||||
|
||||
// Clear all timeouts
|
||||
if (timeoutId !== null) {
|
||||
window.clearTimeout(timeoutId);
|
||||
@ -257,4 +263,3 @@ export async function getDirectResponse(noteId: string, messageParams: any): Pro
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -178,6 +178,11 @@ export default class LlmChatPanel extends BasicWidget {
|
||||
toolbar: {
|
||||
items: [] // No toolbar for chat input
|
||||
},
|
||||
// Remove plugins that require toolbar items but aren't used in chat input
|
||||
removePlugins: [
|
||||
'Image', 'ImageToolbar', 'ImageCaption', 'ImageStyle', 'ImageResize',
|
||||
'ImageInsert', 'ImageUpload', 'PictureEditing', 'AutoImage'
|
||||
],
|
||||
placeholder: this.noteContextChatInput.getAttribute('data-placeholder') || 'Enter your message...',
|
||||
mention: {
|
||||
feeds: mentionSetup
|
||||
@ -824,6 +829,10 @@ export default class LlmChatPanel extends BasicWidget {
|
||||
showLoadingIndicator(this.loadingIndicator);
|
||||
this.hideSources();
|
||||
|
||||
// Track assistant messages count before sending to detect if we got a valid response
|
||||
// This handles cases where streaming "fails" but content was already received via WebSocket
|
||||
const assistantMessagesCountBefore = this.messages.filter(m => m.role === 'assistant').length;
|
||||
|
||||
try {
|
||||
const useAdvancedContext = this.useAdvancedContextCheckbox.checked;
|
||||
const showThinking = this.showThinkingCheckbox.checked;
|
||||
@ -843,6 +852,19 @@ export default class LlmChatPanel extends BasicWidget {
|
||||
try {
|
||||
await this.setupStreamingResponse(messageParams);
|
||||
} catch (streamingError) {
|
||||
// Check if we already received valid content via WebSocket before the error
|
||||
const assistantMessagesCountAfter = this.messages.filter(m => m.role === 'assistant').length;
|
||||
const receivedValidResponse = assistantMessagesCountAfter > assistantMessagesCountBefore;
|
||||
|
||||
if (receivedValidResponse) {
|
||||
// Streaming had an error but content was already received
|
||||
console.warn("WebSocket streaming ended with error but valid response was already received, ignoring error");
|
||||
hideLoadingIndicator(this.loadingIndicator);
|
||||
// Still save even if there was an error, as we may have received partial content
|
||||
await this.saveCurrentData();
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn("WebSocket streaming failed, falling back to direct response:", streamingError);
|
||||
|
||||
// If streaming fails, fall back to direct response
|
||||
@ -859,12 +881,18 @@ export default class LlmChatPanel extends BasicWidget {
|
||||
console.error('Error processing user message:', error);
|
||||
toastService.showError('Failed to process message');
|
||||
|
||||
// Add a generic error message to the UI
|
||||
this.addMessageToChat('assistant', 'Sorry, I encountered an error processing your message. Please try again.');
|
||||
this.messages.push({
|
||||
role: 'assistant',
|
||||
content: 'Sorry, I encountered an error processing your message. Please try again.'
|
||||
});
|
||||
// Double-check if we received valid content before showing error
|
||||
const assistantMessagesCountAfter = this.messages.filter(m => m.role === 'assistant').length;
|
||||
const receivedValidResponse = assistantMessagesCountAfter > assistantMessagesCountBefore;
|
||||
|
||||
// Only add "Sorry" message if we haven't received a valid response
|
||||
if (!receivedValidResponse) {
|
||||
this.addMessageToChat('assistant', 'Sorry, I encountered an error processing your message. Please try again.');
|
||||
this.messages.push({
|
||||
role: 'assistant',
|
||||
content: 'Sorry, I encountered an error processing your message. Please try again.'
|
||||
});
|
||||
}
|
||||
|
||||
// Save the data even after error
|
||||
await this.saveCurrentData();
|
||||
|
||||
@ -61,6 +61,7 @@ function ProviderSettings() {
|
||||
{ value: "", text: t("ai_llm.select_provider") },
|
||||
{ value: "openai", text: "OpenAI" },
|
||||
{ value: "anthropic", text: "Anthropic" },
|
||||
{ value: "minimax", text: t("ai_llm.minimax_tab") },
|
||||
{ value: "ollama", text: "Ollama" }
|
||||
]}
|
||||
currentValue={aiSelectedProvider} onChange={setAiSelectedProvider}
|
||||
@ -98,6 +99,16 @@ function ProviderSettings() {
|
||||
baseUrlOption="ollamaBaseUrl"
|
||||
provider={aiSelectedProvider} modelOption="ollamaDefaultModel"
|
||||
/>
|
||||
: aiSelectedProvider === "minimax" ?
|
||||
<SingleProviderSettings
|
||||
title={t("ai_llm.minimax_settings")}
|
||||
apiKeyDescription={t("ai_llm.minimax_api_key_description")}
|
||||
baseUrlDescription={t("ai_llm.minimax_url_description")}
|
||||
modelDescription={t("ai_llm.minimax_model_description")}
|
||||
validationErrorMessage={t("ai_llm.empty_key_warning.minimax")}
|
||||
apiKeyOption="minimaxApiKey" baseUrlOption="minimaxBaseUrl" modelOption="minimaxDefaultModel"
|
||||
provider={aiSelectedProvider}
|
||||
/>
|
||||
:
|
||||
<></>
|
||||
}
|
||||
@ -179,7 +190,8 @@ function ModelSelector({ provider, baseUrl, modelOption }: { provider: string; b
|
||||
const loadProviders = useCallback(async () => {
|
||||
switch (provider) {
|
||||
case "openai":
|
||||
case "anthropic": {
|
||||
case "anthropic":
|
||||
case "minimax": {
|
||||
try {
|
||||
const response = await server.get<OpenAiOrAnthropicModelResponse>(`llm/providers/${provider}/models?baseUrl=${encodeURIComponent(baseUrl)}`);
|
||||
if (response.success) {
|
||||
|
||||
@ -17,6 +17,10 @@ IMPORTANT: When working with notes in Trilium:
|
||||
- When tools require a noteId parameter, always use the system ID, not the title
|
||||
- Always use search tools first to find notes and get their IDs before performing operations on them
|
||||
- Using a note's title instead of its ID will cause operations to fail
|
||||
- Text notes store HTML (Trilium rich text). When creating or updating text notes via tools, provide HTML content. If you must provide Markdown, specify a Markdown MIME type or rely on automatic conversion.
|
||||
- Internal links must be reference links: <a class="reference-link" href="#root/<notePath>">Title</a>. Backlinks are automatic.
|
||||
- Non-text note types (mermaid/canvas/mindMap/relationMap/file/image/render/webView/search) have specialized formats or rely on attributes; do not mimic them with HTML.
|
||||
- There is no dedicated folder note type; any note can have children.
|
||||
|
||||
When responding to queries:
|
||||
- For complex queries, decompose them into simpler parts and address each one
|
||||
|
||||
@ -566,6 +566,9 @@ async function handleStreamingProcess(
|
||||
throw new Error("No valid AI model configuration found");
|
||||
}
|
||||
|
||||
let accumulatedResponse = '';
|
||||
let savedFinalResponse = false;
|
||||
|
||||
const pipelineInput = {
|
||||
messages: chat.messages.map(msg => ({
|
||||
role: msg.role as 'user' | 'assistant' | 'system',
|
||||
@ -588,6 +591,14 @@ async function handleStreamingProcess(
|
||||
};
|
||||
|
||||
if (data) {
|
||||
const isCompletePayload = rawChunk?.raw && typeof rawChunk.raw === 'object' && (rawChunk.raw as { complete?: boolean }).complete;
|
||||
if (isCompletePayload) {
|
||||
accumulatedResponse = data;
|
||||
} else if (done && accumulatedResponse && data.startsWith(accumulatedResponse)) {
|
||||
accumulatedResponse = data;
|
||||
} else {
|
||||
accumulatedResponse += data;
|
||||
}
|
||||
(message as any).content = data;
|
||||
}
|
||||
|
||||
@ -611,11 +622,16 @@ async function handleStreamingProcess(
|
||||
wsService.sendMessageToAllClients(message);
|
||||
|
||||
// Save final response when done
|
||||
if (done && data) {
|
||||
if (done) {
|
||||
const finalResponse = accumulatedResponse || data || '';
|
||||
if (!finalResponse) {
|
||||
return;
|
||||
}
|
||||
chat.messages.push({
|
||||
role: 'assistant',
|
||||
content: data
|
||||
content: finalResponse
|
||||
});
|
||||
savedFinalResponse = true;
|
||||
chatStorageService.updateChat(chat.id, chat.messages, chat.title).catch(err => {
|
||||
log.error(`Error saving streamed response: ${err}`);
|
||||
});
|
||||
@ -624,7 +640,16 @@ async function handleStreamingProcess(
|
||||
};
|
||||
|
||||
// Execute the pipeline
|
||||
await pipeline.execute(pipelineInput);
|
||||
const pipelineResult = await pipeline.execute(pipelineInput);
|
||||
if (!savedFinalResponse && pipelineResult?.text) {
|
||||
chat.messages.push({
|
||||
role: 'assistant',
|
||||
content: pipelineResult.text
|
||||
});
|
||||
chatStorageService.updateChat(chat.id, chat.messages, chat.title).catch(err => {
|
||||
log.error(`Error saving final response after streaming: ${err}`);
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
log.error(`Error in direct streaming: ${error.message}`);
|
||||
|
||||
70
apps/server/src/routes/api/minimax.ts
Normal file
70
apps/server/src/routes/api/minimax.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import options from "../../services/options.js";
|
||||
import log from "../../services/log.js";
|
||||
import type { Request, Response } from "express";
|
||||
import { PROVIDER_CONSTANTS } from '../../services/llm/constants/provider_constants.js';
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/llm/providers/minimax/models:
|
||||
* get:
|
||||
* summary: List available models from MiniMax
|
||||
* operationId: minimax-list-models
|
||||
* responses:
|
||||
* '200':
|
||||
* description: List of available MiniMax models
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* chatModels:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* id:
|
||||
* type: string
|
||||
* name:
|
||||
* type: string
|
||||
* type:
|
||||
* type: string
|
||||
* '500':
|
||||
* description: Error listing models
|
||||
* security:
|
||||
* - session: []
|
||||
* tags: ["llm"]
|
||||
*/
|
||||
async function listModels(req: Request, res: Response) {
|
||||
try {
|
||||
const apiKey = await options.getOption('minimaxApiKey');
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error('MiniMax API key is not configured');
|
||||
}
|
||||
|
||||
log.info(`Using predefined MiniMax models list (avoiding direct API call)`);
|
||||
|
||||
const chatModels = PROVIDER_CONSTANTS.MINIMAX.AVAILABLE_MODELS.map(model => ({
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
type: 'chat'
|
||||
}));
|
||||
|
||||
// Return the models list
|
||||
return {
|
||||
success: true,
|
||||
chatModels
|
||||
};
|
||||
} catch (error: any) {
|
||||
log.error(`Error listing MiniMax models: ${error.message || 'Unknown error'}`);
|
||||
|
||||
// Properly throw the error to be handled by the global error handler
|
||||
throw new Error(`Failed to list MiniMax models: ${error.message || 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
listModels
|
||||
};
|
||||
@ -117,6 +117,9 @@ const ALLOWED_OPTIONS = new Set<OptionNames>([
|
||||
"anthropicDefaultModel",
|
||||
"ollamaBaseUrl",
|
||||
"ollamaDefaultModel",
|
||||
"minimaxApiKey",
|
||||
"minimaxBaseUrl",
|
||||
"minimaxDefaultModel",
|
||||
"mfaEnabled",
|
||||
"mfaMethod"
|
||||
]);
|
||||
|
||||
@ -37,6 +37,7 @@ import keysRoute from "./api/keys.js";
|
||||
import llmRoute from "./api/llm.js";
|
||||
import loginApiRoute from "./api/login.js";
|
||||
import metricsRoute from "./api/metrics.js";
|
||||
import minimaxRoute from "./api/minimax.js";
|
||||
import noteMapRoute from "./api/note_map.js";
|
||||
import notesApiRoute from "./api/notes.js";
|
||||
import ollamaRoute from "./api/ollama.js";
|
||||
@ -380,6 +381,7 @@ function register(app: express.Application) {
|
||||
asyncApiRoute(GET, "/api/llm/providers/ollama/models", ollamaRoute.listModels);
|
||||
asyncApiRoute(GET, "/api/llm/providers/openai/models", openaiRoute.listModels);
|
||||
asyncApiRoute(GET, "/api/llm/providers/anthropic/models", anthropicRoute.listModels);
|
||||
asyncApiRoute(GET, "/api/llm/providers/minimax/models", minimaxRoute.listModels);
|
||||
|
||||
app.use("", router);
|
||||
}
|
||||
|
||||
@ -69,6 +69,11 @@ export interface StreamChunk {
|
||||
*/
|
||||
raw?: Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* Thinking/reasoning output from models that support it (e.g., MiniMax)
|
||||
*/
|
||||
thinking?: string;
|
||||
|
||||
/**
|
||||
* Tool calls from the LLM (if any)
|
||||
* These may be accumulated over multiple chunks during streaming
|
||||
@ -213,12 +218,26 @@ export interface ChatResponse {
|
||||
tool_calls?: ToolCall[] | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalized chat response used internally by the pipeline.
|
||||
* Guarantees that tool_calls is always an array.
|
||||
*/
|
||||
export interface NormalizedChatResponse extends ChatResponse {
|
||||
text: string;
|
||||
tool_calls: ToolCall[];
|
||||
}
|
||||
|
||||
export interface AIService {
|
||||
/**
|
||||
* Generate a chat completion response
|
||||
*/
|
||||
generateChatCompletion(messages: Message[], options?: ChatCompletionOptions): Promise<ChatResponse>;
|
||||
|
||||
/**
|
||||
* Normalize provider response for pipeline processing.
|
||||
*/
|
||||
toNormalizedResponse(response: ChatResponse): NormalizedChatResponse;
|
||||
|
||||
/**
|
||||
* Check if the service can be used (API key is set, etc.)
|
||||
*/
|
||||
|
||||
@ -8,6 +8,7 @@ import contextService from './context/services/context_service.js';
|
||||
import log from '../log.js';
|
||||
import { OllamaService } from './providers/ollama_service.js';
|
||||
import { OpenAIService } from './providers/openai_service.js';
|
||||
import { MiniMaxService } from './providers/minimax_service.js';
|
||||
|
||||
// Import interfaces
|
||||
import type {
|
||||
@ -176,7 +177,7 @@ export class AIServiceManager implements IAIServiceManager {
|
||||
getAvailableProviders(): ServiceProviders[] {
|
||||
this.ensureInitialized();
|
||||
|
||||
const allProviders: ServiceProviders[] = ['openai', 'anthropic', 'ollama'];
|
||||
const allProviders: ServiceProviders[] = ['openai', 'anthropic', 'ollama', 'minimax'];
|
||||
const availableProviders: ServiceProviders[] = [];
|
||||
|
||||
for (const providerName of allProviders) {
|
||||
@ -198,6 +199,11 @@ export class AIServiceManager implements IAIServiceManager {
|
||||
availableProviders.push(providerName);
|
||||
}
|
||||
break;
|
||||
case 'minimax':
|
||||
if (options.getOption('minimaxApiKey')) {
|
||||
availableProviders.push(providerName);
|
||||
}
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore configuration errors, provider just won't be available
|
||||
@ -440,6 +446,29 @@ export class AIServiceManager implements IAIServiceManager {
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'minimax': {
|
||||
const apiKey = options.getOption('minimaxApiKey');
|
||||
if (!apiKey) {
|
||||
log.info('MiniMax API key not configured');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
service = new MiniMaxService();
|
||||
if (!service.isAvailable()) {
|
||||
throw new Error('MiniMax service not available');
|
||||
}
|
||||
log.info('MiniMax service created successfully');
|
||||
} catch (error: any) {
|
||||
log.error(`Failed to create MiniMax service: ${error.message || String(error)}`);
|
||||
throw new Error(
|
||||
`Failed to initialize MiniMax service: ${error.message}. ` +
|
||||
'Please check your MiniMax API key and settings.'
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (service) {
|
||||
@ -661,6 +690,8 @@ export class AIServiceManager implements IAIServiceManager {
|
||||
return !!options.getOption('anthropicApiKey');
|
||||
case 'ollama':
|
||||
return !!options.getOption('ollamaBaseUrl');
|
||||
case 'minimax':
|
||||
return !!options.getOption('minimaxApiKey');
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
@ -670,26 +701,96 @@ export class AIServiceManager implements IAIServiceManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get metadata about a provider
|
||||
*/
|
||||
* Get metadata about a provider
|
||||
*/
|
||||
getProviderMetadata(provider: string): ProviderMetadata | null {
|
||||
// Only return metadata if this is the current active provider
|
||||
if (this.currentProvider === provider && this.currentService) {
|
||||
const models = this.getProviderModels(provider);
|
||||
return {
|
||||
name: provider,
|
||||
capabilities: {
|
||||
chat: true,
|
||||
streaming: true,
|
||||
functionCalling: provider === 'openai' // Only OpenAI has function calling
|
||||
// OpenAI, Anthropic, and MiniMax support function calling
|
||||
functionCalling: ['openai', 'anthropic', 'minimax'].includes(provider)
|
||||
},
|
||||
models: ['default'], // Placeholder, could be populated from the service
|
||||
defaultModel: 'default'
|
||||
models: models,
|
||||
defaultModel: this.getDefaultModelForProvider(provider)
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available models for a specific provider
|
||||
*/
|
||||
private getProviderModels(provider: string): string[] {
|
||||
switch (provider) {
|
||||
case 'openai':
|
||||
return [
|
||||
'gpt-4o',
|
||||
'gpt-4o-mini',
|
||||
'gpt-4-turbo',
|
||||
'gpt-4',
|
||||
'gpt-3.5-turbo'
|
||||
];
|
||||
case 'anthropic':
|
||||
return [
|
||||
'claude-sonnet-4-20250514',
|
||||
'claude-sonnet-4',
|
||||
'claude-opus-4',
|
||||
'claude-sonnet-3-20250520',
|
||||
'claude-sonnet-3',
|
||||
'claude-haiku-3',
|
||||
'claude-3-5-sonnet',
|
||||
'claude-3-opus',
|
||||
'claude-3-sonnet',
|
||||
'claude-3-haiku'
|
||||
];
|
||||
case 'ollama':
|
||||
// Ollama models are dynamic, return a common set
|
||||
return [
|
||||
'llama3.3',
|
||||
'llama3.2',
|
||||
'llama3.1',
|
||||
'llama3',
|
||||
'mistral',
|
||||
'codellama',
|
||||
'qwen2.5',
|
||||
'deepseek-r1',
|
||||
'deepseek-v3'
|
||||
];
|
||||
case 'minimax':
|
||||
return [
|
||||
'MiniMax-M2.1',
|
||||
'MiniMax-M2.1-lightning',
|
||||
'MiniMax-M2'
|
||||
];
|
||||
default:
|
||||
return ['default'];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default model for a provider
|
||||
*/
|
||||
private getDefaultModelForProvider(provider: string): string {
|
||||
switch (provider) {
|
||||
case 'openai':
|
||||
return 'gpt-4o-mini';
|
||||
case 'anthropic':
|
||||
return 'claude-sonnet-4-20250514';
|
||||
case 'ollama':
|
||||
return 'llama3.3';
|
||||
case 'minimax':
|
||||
return 'MiniMax-M2.1';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Error handler that properly types the error object
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import options from '../options.js';
|
||||
import type { AIService, ChatCompletionOptions, ChatResponse, Message } from './ai_interface.js';
|
||||
import type { AIService, ChatCompletionOptions, ChatResponse, Message, NormalizedChatResponse } from './ai_interface.js';
|
||||
import { DEFAULT_SYSTEM_PROMPT } from './constants/llm_prompt_constants.js';
|
||||
import { normalizeChatResponse } from './response_normalizer.js';
|
||||
|
||||
export abstract class BaseAIService implements AIService {
|
||||
protected name: string;
|
||||
@ -11,6 +12,10 @@ export abstract class BaseAIService implements AIService {
|
||||
|
||||
abstract generateChatCompletion(messages: Message[], options?: ChatCompletionOptions): Promise<ChatResponse>;
|
||||
|
||||
toNormalizedResponse(response: ChatResponse): NormalizedChatResponse {
|
||||
return normalizeChatResponse(response);
|
||||
}
|
||||
|
||||
isAvailable(): boolean {
|
||||
return options.getOptionBool('aiEnabled'); // Base check if AI is enabled globally
|
||||
}
|
||||
|
||||
@ -227,6 +227,10 @@ describe('ChatStorageService', () => {
|
||||
expect.stringContaining('SELECT notes.noteId, notes.title'),
|
||||
['label', 'triliumChat']
|
||||
);
|
||||
expect(mockSql.getRows).toHaveBeenCalledWith(
|
||||
expect.stringContaining('notes.isDeleted = 0'),
|
||||
['label', 'triliumChat']
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle chats with invalid JSON content', async () => {
|
||||
@ -291,12 +295,12 @@ describe('ChatStorageService', () => {
|
||||
});
|
||||
|
||||
expect(mockSql.getRow).toHaveBeenCalledWith(
|
||||
expect.stringContaining('SELECT notes.noteId, notes.title'),
|
||||
expect.stringContaining('notes.isDeleted = 0'),
|
||||
['chat-123']
|
||||
);
|
||||
});
|
||||
|
||||
it('should return null if chat not found', async () => {
|
||||
it('should return null if chat not found or deleted', async () => {
|
||||
mockSql.getRow.mockResolvedValueOnce(null);
|
||||
|
||||
const result = await chatStorageService.getChat('nonexistent');
|
||||
|
||||
@ -126,7 +126,7 @@ export class ChatStorageService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all chats
|
||||
* Get all chats (excludes soft-deleted chats)
|
||||
*/
|
||||
async getAllChats(): Promise<StoredChat[]> {
|
||||
const chats = await sql.getRows<{noteId: string, title: string, dateCreated: string, dateModified: string, content: string}>(
|
||||
@ -135,6 +135,7 @@ export class ChatStorageService {
|
||||
JOIN blobs ON notes.blobId = blobs.blobId
|
||||
JOIN attributes ON notes.noteId = attributes.noteId
|
||||
WHERE attributes.name = ? AND attributes.value = ?
|
||||
AND notes.isDeleted = 0
|
||||
ORDER BY notes.dateModified DESC`,
|
||||
['label', ChatStorageService.CHAT_LABEL]
|
||||
);
|
||||
@ -174,14 +175,14 @@ export class ChatStorageService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific chat
|
||||
* Get a specific chat (returns null if not found or soft-deleted)
|
||||
*/
|
||||
async getChat(chatId: string): Promise<StoredChat | null> {
|
||||
const chat = await sql.getRow<{noteId: string, title: string, dateCreated: string, dateModified: string, content: string}>(
|
||||
`SELECT notes.noteId, notes.title, notes.dateCreated, notes.dateModified, blobs.content
|
||||
FROM notes
|
||||
JOIN blobs ON notes.blobId = blobs.blobId
|
||||
WHERE notes.noteId = ?`,
|
||||
WHERE notes.noteId = ? AND notes.isDeleted = 0`,
|
||||
[chatId]
|
||||
);
|
||||
|
||||
|
||||
@ -43,7 +43,7 @@ export function parseModelIdentifier(modelString: string): ModelIdentifier {
|
||||
|
||||
// Check if first part is a known provider
|
||||
const potentialProvider = parts[0].toLowerCase();
|
||||
const knownProviders: ProviderType[] = ['openai', 'anthropic', 'ollama'];
|
||||
const knownProviders: ProviderType[] = ['openai', 'anthropic', 'ollama', 'minimax'];
|
||||
|
||||
if (knownProviders.includes(potentialProvider as ProviderType)) {
|
||||
// Provider prefix format
|
||||
@ -87,8 +87,8 @@ export async function getDefaultModelForProvider(provider: ProviderType): Promis
|
||||
}
|
||||
|
||||
/**
|
||||
* Get provider settings for a specific provider - always fresh from options
|
||||
*/
|
||||
* Get provider settings for a specific provider - always fresh from options
|
||||
*/
|
||||
export async function getProviderSettings(provider: ProviderType) {
|
||||
switch (provider) {
|
||||
case 'openai':
|
||||
@ -108,6 +108,12 @@ export async function getProviderSettings(provider: ProviderType) {
|
||||
baseUrl: optionService.getOption('ollamaBaseUrl'),
|
||||
defaultModel: optionService.getOption('ollamaDefaultModel')
|
||||
};
|
||||
case 'minimax':
|
||||
return {
|
||||
apiKey: optionService.getOption('minimaxApiKey'),
|
||||
baseUrl: optionService.getOption('minimaxBaseUrl'),
|
||||
defaultModel: optionService.getOption('minimaxDefaultModel')
|
||||
};
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
@ -121,8 +127,8 @@ export async function isAIEnabled(): Promise<boolean> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a provider has required configuration
|
||||
*/
|
||||
* Check if a provider has required configuration
|
||||
*/
|
||||
export async function isProviderConfigured(provider: ProviderType): Promise<boolean> {
|
||||
const settings = await getProviderSettings(provider);
|
||||
|
||||
@ -133,6 +139,8 @@ export async function isProviderConfigured(provider: ProviderType): Promise<bool
|
||||
return Boolean((settings as any)?.apiKey);
|
||||
case 'ollama':
|
||||
return Boolean((settings as any)?.baseUrl);
|
||||
case 'minimax':
|
||||
return Boolean((settings as any)?.apiKey);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
@ -193,6 +201,10 @@ export async function validateConfiguration() {
|
||||
result.warnings.push('Ollama base URL is not configured');
|
||||
}
|
||||
|
||||
if (selectedProvider === 'minimax' && !(settings as any)?.apiKey) {
|
||||
result.warnings.push('MiniMax API key is not configured');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@ -10,7 +10,8 @@ import type {
|
||||
ProviderSettings,
|
||||
OpenAISettings,
|
||||
AnthropicSettings,
|
||||
OllamaSettings
|
||||
OllamaSettings,
|
||||
MiniMaxSettings
|
||||
} from '../interfaces/configuration_interfaces.js';
|
||||
|
||||
/**
|
||||
@ -88,7 +89,7 @@ export class ConfigurationManager {
|
||||
|
||||
// Check if first part is a known provider
|
||||
const potentialProvider = parts[0].toLowerCase();
|
||||
const knownProviders: ProviderType[] = ['openai', 'anthropic', 'ollama'];
|
||||
const knownProviders: ProviderType[] = ['openai', 'anthropic', 'ollama', 'minimax'];
|
||||
|
||||
if (knownProviders.includes(potentialProvider as ProviderType)) {
|
||||
// Provider prefix format
|
||||
@ -124,18 +125,20 @@ export class ConfigurationManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default models for each provider - ONLY from user configuration
|
||||
*/
|
||||
* Get default models for each provider - ONLY from user configuration
|
||||
*/
|
||||
public async getDefaultModels(): Promise<Record<ProviderType, string | undefined>> {
|
||||
try {
|
||||
const openaiModel = options.getOption('openaiDefaultModel');
|
||||
const anthropicModel = options.getOption('anthropicDefaultModel');
|
||||
const ollamaModel = options.getOption('ollamaDefaultModel');
|
||||
const minimaxModel = options.getOption('minimaxDefaultModel');
|
||||
|
||||
return {
|
||||
openai: openaiModel || undefined,
|
||||
anthropic: anthropicModel || undefined,
|
||||
ollama: ollamaModel || undefined
|
||||
ollama: ollamaModel || undefined,
|
||||
minimax: minimaxModel || undefined
|
||||
};
|
||||
} catch (error) {
|
||||
log.error(`Error loading default models: ${error}`);
|
||||
@ -143,14 +146,15 @@ export class ConfigurationManager {
|
||||
return {
|
||||
openai: undefined,
|
||||
anthropic: undefined,
|
||||
ollama: undefined
|
||||
ollama: undefined,
|
||||
minimax: undefined
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get provider-specific settings
|
||||
*/
|
||||
* Get provider-specific settings
|
||||
*/
|
||||
public async getProviderSettings(): Promise<ProviderSettings> {
|
||||
try {
|
||||
const openaiApiKey = options.getOption('openaiApiKey');
|
||||
@ -161,6 +165,9 @@ export class ConfigurationManager {
|
||||
const anthropicDefaultModel = options.getOption('anthropicDefaultModel');
|
||||
const ollamaBaseUrl = options.getOption('ollamaBaseUrl');
|
||||
const ollamaDefaultModel = options.getOption('ollamaDefaultModel');
|
||||
const minimaxApiKey = options.getOption('minimaxApiKey');
|
||||
const minimaxBaseUrl = options.getOption('minimaxBaseUrl');
|
||||
const minimaxDefaultModel = options.getOption('minimaxDefaultModel');
|
||||
|
||||
const settings: ProviderSettings = {};
|
||||
|
||||
@ -187,6 +194,14 @@ export class ConfigurationManager {
|
||||
};
|
||||
}
|
||||
|
||||
if (minimaxApiKey || minimaxBaseUrl || minimaxDefaultModel) {
|
||||
settings.minimax = {
|
||||
apiKey: minimaxApiKey,
|
||||
baseUrl: minimaxBaseUrl,
|
||||
defaultModel: minimaxDefaultModel
|
||||
};
|
||||
}
|
||||
|
||||
return settings;
|
||||
} catch (error) {
|
||||
log.error(`Error loading provider settings: ${error}`);
|
||||
@ -240,6 +255,13 @@ export class ConfigurationManager {
|
||||
result.warnings.push('Ollama base URL is not configured');
|
||||
}
|
||||
}
|
||||
|
||||
if (config.selectedProvider === 'minimax') {
|
||||
const minimaxConfig = providerConfig as MiniMaxSettings | undefined;
|
||||
if (!minimaxConfig?.apiKey) {
|
||||
result.warnings.push('MiniMax API key is not configured');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -298,7 +320,8 @@ export class ConfigurationManager {
|
||||
defaultModels: {
|
||||
openai: undefined,
|
||||
anthropic: undefined,
|
||||
ollama: undefined
|
||||
ollama: undefined,
|
||||
minimax: undefined
|
||||
},
|
||||
providerSettings: {}
|
||||
};
|
||||
|
||||
@ -195,7 +195,7 @@ When using tools to search for information, follow these requirements:
|
||||
- Try broader terms (e.g., "Kubernetes" instead of "Kubernetes deployment")
|
||||
- Use synonyms (e.g., "meeting" instead of "conference")
|
||||
- Remove specific qualifiers (e.g., "report" instead of "Q3 financial report")
|
||||
- Try different search tools (vector_search for conceptual matches, keyword_search for exact matches)
|
||||
- Try different search tools (search_notes for conceptual matches, keyword_search_notes for exact matches)
|
||||
4. NEVER tell the user "there are no notes about X" until you've tried multiple search variations
|
||||
5. EXPLAIN your search strategy when adjusting parameters (e.g., "I'll try a broader search for...")
|
||||
6. When searches fail, AUTOMATICALLY try different approaches rather than asking the user what to do
|
||||
@ -230,7 +230,7 @@ Be concise and informative in your responses.
|
||||
- Try broader terms (e.g., "Kubernetes" instead of "Kubernetes deployment")
|
||||
- Use synonyms (e.g., "meeting" instead of "conference")
|
||||
- Remove specific qualifiers (e.g., "report" instead of "Q3 financial report")
|
||||
- Try different search tools (vector_search for conceptual matches, keyword_search for exact matches)
|
||||
- Try different search tools (search_notes for conceptual matches, keyword_search_notes for exact matches)
|
||||
4. NEVER tell the user "there are no notes about X" until you've tried multiple search variations
|
||||
5. EXPLAIN your search strategy when adjusting parameters (e.g., "I'll try a broader search for...")
|
||||
6. When searches fail, AUTOMATICALLY try different approaches rather than asking the user what to do`
|
||||
@ -253,13 +253,56 @@ CRITICAL INSTRUCTIONS FOR TOOL USAGE:
|
||||
3. If a search returns no results, IMMEDIATELY TRY ANOTHER SEARCH with different parameters:
|
||||
- Use broader terms: If "Kubernetes deployment" fails, try just "Kubernetes" or "container orchestration"
|
||||
- Try synonyms: If "meeting notes" fails, try "conference", "discussion", or "conversation"
|
||||
- Remove specific qualifiers: If "quarterly financial report 2024" fails, try just "financial report"
|
||||
- Try semantic variations: If keyword_search fails, use vector_search which finds conceptually related content
|
||||
4. CHAIN TOOLS TOGETHER: Use the results of one tool to inform parameters for the next tool
|
||||
5. NEVER respond with "there are no notes about X" until you've tried at least 3 different search variations
|
||||
6. DO NOT ask the user what to do next when searches fail - AUTOMATICALLY try different approaches
|
||||
7. ALWAYS EXPLAIN what you're doing: "I didn't find results for X, so I'm now searching for Y instead"
|
||||
8. If all reasonable search variations fail (minimum 3 attempts), THEN you may inform the user that the information might not be in their notes`
|
||||
- Remove specific qualifiers: If "quarterly financial report 2024" fails, try just "financial report"
|
||||
- Try semantic variations: If keyword_search_notes fails, use search_notes which finds conceptually related content
|
||||
4. CHAIN TOOLS TOGETHER: Use the results of one tool to inform parameters for the next tool
|
||||
5. NEVER respond with "there are no notes about X" until you've tried at least 3 different search variations
|
||||
6. DO NOT ask the user what to do next when searches fail - AUTOMATICALLY try different approaches
|
||||
7. ALWAYS EXPLAIN what you're doing: "I didn't find results for X, so I'm now searching for Y instead"
|
||||
8. If all reasonable search variations fail (minimum 3 attempts), THEN you may inform the user that the information might not be in their notes`
|
||||
},
|
||||
|
||||
MINIMAX: {
|
||||
// MiniMax uses Anthropic-compatible API, so we use similar prompts
|
||||
SYSTEM_WITH_CONTEXT: (context: string) =>
|
||||
`<instructions>
|
||||
${DEFAULT_SYSTEM_PROMPT}
|
||||
|
||||
Use the following information from the user's notes to answer their questions:
|
||||
|
||||
<user_notes>
|
||||
${context}
|
||||
</user_notes>
|
||||
|
||||
When responding:
|
||||
- Focus on the most relevant information from the notes
|
||||
- Be concise and direct in your answers
|
||||
- If quoting from notes, mention which note it's from
|
||||
- If the notes don't contain relevant information, say so clearly
|
||||
</instructions>`,
|
||||
|
||||
INSTRUCTIONS_WRAPPER: (instructions: string) =>
|
||||
`<instructions>\n${instructions}\n</instructions>`,
|
||||
|
||||
// Tool instructions for MiniMax (Anthropic-compatible)
|
||||
TOOL_INSTRUCTIONS: `<instructions>
|
||||
When using tools to search for information, follow these requirements:
|
||||
|
||||
1. ALWAYS TRY MULTIPLE SEARCH APPROACHES before concluding information isn't available
|
||||
2. YOU MUST PERFORM AT LEAST 3 DIFFERENT SEARCHES with varied parameters before giving up
|
||||
3. If a search returns no results:
|
||||
- Try broader terms (e.g., "Kubernetes" instead of "Kubernetes deployment")
|
||||
- Use synonyms (e.g., "meeting" instead of "conference")
|
||||
- Remove specific qualifiers (e.g., "report" instead of "Q3 financial report")
|
||||
- Try different search tools (search_notes for conceptual matches, keyword_search_notes for exact matches)
|
||||
4. NEVER tell the user "there are no notes about X" until you've tried multiple search variations
|
||||
5. EXPLAIN your search strategy when adjusting parameters (e.g., "I'll try a broader search for...")
|
||||
6. When searches fail, AUTOMATICALLY try different approaches rather than asking the user what to do
|
||||
</instructions>`,
|
||||
|
||||
ACKNOWLEDGMENT: "I understand. I'll follow those instructions.",
|
||||
CONTEXT_ACKNOWLEDGMENT: "I'll help you with your notes based on the context provided.",
|
||||
CONTEXT_QUERY_ACKNOWLEDGMENT: "I'll help you with your notes based on the context provided. What would you like to know?"
|
||||
},
|
||||
|
||||
// Common prompts across providers
|
||||
|
||||
@ -106,6 +106,56 @@ export const PROVIDER_CONSTANTS = {
|
||||
mixtral: 8192,
|
||||
'mistral': 8192
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* MiniMax provider constants
|
||||
* Uses Anthropic-compatible API endpoint
|
||||
* Documentation: https://platform.minimax.io/docs/
|
||||
*/
|
||||
MINIMAX: {
|
||||
BASE_URL: 'https://api.minimax.io/anthropic',
|
||||
DEFAULT_MODEL: 'MiniMax-M2.1',
|
||||
API_VERSION: '2023-06-01',
|
||||
CONTEXT_WINDOW: 200000,
|
||||
AVAILABLE_MODELS: [
|
||||
{
|
||||
id: 'MiniMax-M2.1',
|
||||
name: 'MiniMax M2.1',
|
||||
description: 'Full capability model with 230B parameters, 10B activated',
|
||||
maxTokens: 128000,
|
||||
contextWindow: 200000,
|
||||
capabilities: {
|
||||
supportsTools: true,
|
||||
supportsStreaming: true,
|
||||
supportsVision: false
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'MiniMax-M2.1-lightning',
|
||||
name: 'MiniMax M2.1 Lightning',
|
||||
description: 'Fast model for low-latency responses (~100 tps)',
|
||||
maxTokens: 128000,
|
||||
contextWindow: 200000,
|
||||
capabilities: {
|
||||
supportsTools: true,
|
||||
supportsStreaming: true,
|
||||
supportsVision: false
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'MiniMax-M2',
|
||||
name: 'MiniMax M2',
|
||||
description: 'Balanced model with agentic capabilities, 200k context',
|
||||
maxTokens: 128000,
|
||||
contextWindow: 200000,
|
||||
capabilities: {
|
||||
supportsTools: true,
|
||||
supportsStreaming: true,
|
||||
supportsVision: false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
} as const;
|
||||
|
||||
@ -158,5 +208,8 @@ export const LLM_CONSTANTS = {
|
||||
// AI Feature Exclusion
|
||||
AI_EXCLUSION: {
|
||||
LABEL_NAME: 'aiExclude' // Label used to exclude notes from all AI/LLM features
|
||||
}
|
||||
},
|
||||
|
||||
// MiniMax provider constants (for anthropic-compatible API)
|
||||
MINIMAX: 'minimax'
|
||||
};
|
||||
|
||||
@ -38,7 +38,7 @@ export interface IAIServiceManager {
|
||||
/**
|
||||
* Type for service providers
|
||||
*/
|
||||
export type ServiceProviders = 'openai' | 'anthropic' | 'ollama';
|
||||
export type ServiceProviders = 'openai' | 'anthropic' | 'ollama' | 'minimax';
|
||||
|
||||
/**
|
||||
* LLM model configuration
|
||||
|
||||
@ -51,6 +51,7 @@ export interface ProviderSettings {
|
||||
openai?: OpenAISettings;
|
||||
anthropic?: AnthropicSettings;
|
||||
ollama?: OllamaSettings;
|
||||
minimax?: MiniMaxSettings;
|
||||
}
|
||||
|
||||
export interface OpenAISettings {
|
||||
@ -71,10 +72,16 @@ export interface OllamaSettings {
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export interface MiniMaxSettings {
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
defaultModel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Valid provider types
|
||||
*/
|
||||
export type ProviderType = 'openai' | 'anthropic' | 'ollama';
|
||||
export type ProviderType = 'openai' | 'anthropic' | 'ollama' | 'minimax';
|
||||
|
||||
|
||||
/**
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { ChatPipelineInput, ChatPipelineConfig, PipelineMetrics, StreamCallback } from './interfaces.js';
|
||||
import type { ChatResponse, StreamChunk, Message } from '../ai_interface.js';
|
||||
import type { ChatResponse } from '../ai_interface.js';
|
||||
import { ContextExtractionStage } from './stages/context_extraction_stage.js';
|
||||
import { SemanticContextExtractionStage } from './stages/semantic_context_extraction_stage.js';
|
||||
import { AgentToolsContextStage } from './stages/agent_tools_context_stage.js';
|
||||
@ -10,10 +10,12 @@ import { ResponseProcessingStage } from './stages/response_processing_stage.js';
|
||||
import { ToolCallingStage } from './stages/tool_calling_stage.js';
|
||||
// Traditional search is used instead of vector search
|
||||
import toolRegistry from '../tools/tool_registry.js';
|
||||
import toolInitializer from '../tools/tool_initializer.js';
|
||||
import log from '../../log.js';
|
||||
import type { LLMServiceInterface } from '../interfaces/agent_tool_interfaces.js';
|
||||
import { SEARCH_CONSTANTS } from '../constants/search_constants.js';
|
||||
import { DefaultStreamingStrategy } from './streaming/default_streaming_strategy.js';
|
||||
import type { StreamingStrategy } from './streaming/streaming_strategy.js';
|
||||
import { ChatPipelineToolLoop } from './chat_pipeline_tool_loop.js';
|
||||
|
||||
/**
|
||||
* Pipeline for managing the entire chat flow
|
||||
@ -34,6 +36,7 @@ export class ChatPipeline {
|
||||
|
||||
config: ChatPipelineConfig;
|
||||
metrics: PipelineMetrics;
|
||||
streamingStrategy: StreamingStrategy;
|
||||
|
||||
/**
|
||||
* Create a new chat pipeline
|
||||
@ -68,6 +71,8 @@ export class ChatPipeline {
|
||||
stageMetrics: {}
|
||||
};
|
||||
|
||||
this.streamingStrategy = new DefaultStreamingStrategy();
|
||||
|
||||
// Initialize stage metrics
|
||||
Object.keys(this.stages).forEach(stageName => {
|
||||
this.metrics.stageMetrics[stageName] = {
|
||||
@ -88,8 +93,8 @@ export class ChatPipeline {
|
||||
this.metrics.totalExecutions++;
|
||||
|
||||
// Initialize streaming handler if requested
|
||||
let streamCallback = input.streamCallback;
|
||||
let accumulatedText = '';
|
||||
const streamCallback = input.streamCallback;
|
||||
const accumulatedText = '';
|
||||
|
||||
try {
|
||||
// Extract content length for model selection
|
||||
@ -127,7 +132,7 @@ export class ChatPipeline {
|
||||
log.info(`Selected model: ${modelSelection.options.model || 'default'}, enableTools: ${modelSelection.options.enableTools}`);
|
||||
|
||||
// Determine if we should use tools or semantic context
|
||||
const useTools = modelSelection.options.enableTools === true;
|
||||
const useTools = modelSelection.options.enableTools !== false;
|
||||
const useEnhancedContext = input.options?.useAdvancedContext === true;
|
||||
|
||||
// Log details about the advanced context parameter
|
||||
@ -241,584 +246,57 @@ export class ChatPipeline {
|
||||
|
||||
// Setup streaming handler if streaming is enabled and callback provided
|
||||
// Check if streaming should be enabled based on several conditions
|
||||
const streamEnabledInConfig = this.config.enableStreaming;
|
||||
const streamFormatRequested = input.format === 'stream';
|
||||
const streamRequestedInOptions = modelSelection.options.stream === true;
|
||||
const streamCallbackAvailable = typeof streamCallback === 'function';
|
||||
const providerName = modelSelection.options.providerMetadata?.provider;
|
||||
|
||||
log.info(`[ChatPipeline] Request type info - Format: ${input.format || 'not specified'}, Options from pipelineInput: ${JSON.stringify({stream: input.options?.stream})}`);
|
||||
log.info(`[ChatPipeline] Stream settings - config.enableStreaming: ${streamEnabledInConfig}, format parameter: ${input.format}, modelSelection.options.stream: ${modelSelection.options.stream}, streamCallback available: ${streamCallbackAvailable}`);
|
||||
log.info(`[ChatPipeline] Stream settings - config.enableStreaming: ${this.config.enableStreaming}, format parameter: ${input.format}, modelSelection.options.stream: ${modelSelection.options.stream}, streamCallback available: ${streamCallbackAvailable}`);
|
||||
|
||||
// IMPORTANT: Respect the existing stream option but with special handling for callbacks:
|
||||
// 1. If a stream callback is available, streaming MUST be enabled for it to work
|
||||
// 2. Otherwise, preserve the original stream setting from input options
|
||||
const streamingDecision = this.streamingStrategy.resolveInitialStreaming({
|
||||
configEnableStreaming: this.config.enableStreaming,
|
||||
format: typeof input.format === 'string' ? input.format : undefined,
|
||||
optionStream: modelSelection.options.stream,
|
||||
hasStreamCallback: streamCallbackAvailable,
|
||||
providerName,
|
||||
toolsEnabled: useTools
|
||||
});
|
||||
|
||||
// First, determine what the stream value should be based on various factors:
|
||||
let shouldEnableStream = modelSelection.options.stream;
|
||||
const shouldEnableStream = streamingDecision.clientStream;
|
||||
const providerStream = streamingDecision.providerStream;
|
||||
|
||||
if (streamCallbackAvailable) {
|
||||
// If we have a stream callback, we NEED to enable streaming
|
||||
// This is critical for GET requests with EventSource
|
||||
shouldEnableStream = true;
|
||||
log.info(`[ChatPipeline] Stream callback available, enabling streaming`);
|
||||
} else if (streamRequestedInOptions) {
|
||||
// Stream was explicitly requested in options, honor that setting
|
||||
log.info(`[ChatPipeline] Stream explicitly requested in options: ${streamRequestedInOptions}`);
|
||||
shouldEnableStream = streamRequestedInOptions;
|
||||
} else if (streamFormatRequested) {
|
||||
// Format=stream parameter indicates streaming was requested
|
||||
log.info(`[ChatPipeline] Stream format requested in parameters`);
|
||||
shouldEnableStream = true;
|
||||
} else {
|
||||
// No explicit streaming indicators, use config default
|
||||
log.info(`[ChatPipeline] No explicit stream settings, using config default: ${streamEnabledInConfig}`);
|
||||
shouldEnableStream = streamEnabledInConfig;
|
||||
}
|
||||
|
||||
// Set the final stream option
|
||||
modelSelection.options.stream = shouldEnableStream;
|
||||
|
||||
log.info(`[ChatPipeline] Final streaming decision: stream=${shouldEnableStream}, will stream to client=${streamCallbackAvailable && shouldEnableStream}`);
|
||||
if (shouldEnableStream !== providerStream) {
|
||||
log.info(`[ChatPipeline] Provider streaming adjusted: providerStream=${providerStream}`);
|
||||
}
|
||||
|
||||
|
||||
// STAGE 5 & 6: Handle LLM completion and tool execution loop
|
||||
log.info(`========== STAGE 5: LLM COMPLETION ==========`);
|
||||
const llmStartTime = Date.now();
|
||||
const completion = await this.stages.llmCompletion.execute({
|
||||
messages: preparedMessages.messages,
|
||||
options: modelSelection.options
|
||||
const toolLoop = new ChatPipelineToolLoop({
|
||||
stages: {
|
||||
llmCompletion: this.stages.llmCompletion,
|
||||
toolCalling: this.stages.toolCalling,
|
||||
responseProcessing: this.stages.responseProcessing
|
||||
},
|
||||
streamingStrategy: this.streamingStrategy,
|
||||
updateStageMetrics: this.updateStageMetrics.bind(this)
|
||||
});
|
||||
this.updateStageMetrics('llmCompletion', llmStartTime);
|
||||
log.info(`Received LLM response from model: ${completion.response.model}, provider: ${completion.response.provider}`);
|
||||
|
||||
// Track whether content has been streamed to prevent duplication
|
||||
let hasStreamedContent = false;
|
||||
|
||||
// Handle streaming if enabled and available
|
||||
// Use shouldEnableStream variable which contains our streaming decision
|
||||
if (shouldEnableStream && completion.response.stream && streamCallback) {
|
||||
// Setup stream handler that passes chunks through response processing
|
||||
await completion.response.stream(async (chunk: StreamChunk) => {
|
||||
// Process the chunk text
|
||||
const processedChunk = await this.processStreamChunk(chunk, input.options);
|
||||
|
||||
// Accumulate text for final response
|
||||
accumulatedText += processedChunk.text;
|
||||
|
||||
// Forward to callback with original chunk data in case it contains additional information
|
||||
streamCallback(processedChunk.text, processedChunk.done, chunk);
|
||||
|
||||
// Mark that we have streamed content to prevent duplication
|
||||
hasStreamedContent = true;
|
||||
});
|
||||
}
|
||||
|
||||
// Process any tool calls in the response
|
||||
let currentMessages = preparedMessages.messages;
|
||||
let currentResponse = completion.response;
|
||||
let toolCallIterations = 0;
|
||||
const maxToolCallIterations = this.config.maxToolCallIterations;
|
||||
|
||||
// Check if tools were enabled in the options
|
||||
const toolsEnabled = modelSelection.options.enableTools !== false;
|
||||
|
||||
// Log decision points for tool execution
|
||||
log.info(`========== TOOL EXECUTION DECISION ==========`);
|
||||
log.info(`Tools enabled in options: ${toolsEnabled}`);
|
||||
log.info(`Response provider: ${currentResponse.provider || 'unknown'}`);
|
||||
log.info(`Response model: ${currentResponse.model || 'unknown'}`);
|
||||
|
||||
// Enhanced tool_calls detection - check both direct property and getter
|
||||
let hasToolCalls = false;
|
||||
|
||||
log.info(`[TOOL CALL DEBUG] Starting tool call detection for provider: ${currentResponse.provider}`);
|
||||
// Check response object structure
|
||||
log.info(`[TOOL CALL DEBUG] Response properties: ${Object.keys(currentResponse).join(', ')}`);
|
||||
|
||||
// Try to access tool_calls as a property
|
||||
if ('tool_calls' in currentResponse) {
|
||||
log.info(`[TOOL CALL DEBUG] tool_calls exists as a direct property`);
|
||||
log.info(`[TOOL CALL DEBUG] tool_calls type: ${typeof currentResponse.tool_calls}`);
|
||||
|
||||
if (currentResponse.tool_calls && Array.isArray(currentResponse.tool_calls)) {
|
||||
log.info(`[TOOL CALL DEBUG] tool_calls is an array with length: ${currentResponse.tool_calls.length}`);
|
||||
} else {
|
||||
log.info(`[TOOL CALL DEBUG] tool_calls is not an array or is empty: ${JSON.stringify(currentResponse.tool_calls)}`);
|
||||
}
|
||||
} else {
|
||||
log.info(`[TOOL CALL DEBUG] tool_calls does not exist as a direct property`);
|
||||
}
|
||||
|
||||
// First check the direct property
|
||||
if (currentResponse.tool_calls && currentResponse.tool_calls.length > 0) {
|
||||
hasToolCalls = true;
|
||||
log.info(`Response has tool_calls property with ${currentResponse.tool_calls.length} tools`);
|
||||
log.info(`Tool calls details: ${JSON.stringify(currentResponse.tool_calls)}`);
|
||||
}
|
||||
// Check if it might be a getter (for dynamic tool_calls collection)
|
||||
else {
|
||||
log.info(`[TOOL CALL DEBUG] Direct property check failed, trying getter approach`);
|
||||
try {
|
||||
const toolCallsDesc = Object.getOwnPropertyDescriptor(currentResponse, 'tool_calls');
|
||||
|
||||
if (toolCallsDesc) {
|
||||
log.info(`[TOOL CALL DEBUG] Found property descriptor for tool_calls: ${JSON.stringify({
|
||||
configurable: toolCallsDesc.configurable,
|
||||
enumerable: toolCallsDesc.enumerable,
|
||||
hasGetter: !!toolCallsDesc.get,
|
||||
hasSetter: !!toolCallsDesc.set
|
||||
})}`);
|
||||
} else {
|
||||
log.info(`[TOOL CALL DEBUG] No property descriptor found for tool_calls`);
|
||||
}
|
||||
|
||||
if (toolCallsDesc && typeof toolCallsDesc.get === 'function') {
|
||||
log.info(`[TOOL CALL DEBUG] Attempting to call the tool_calls getter`);
|
||||
const dynamicToolCalls = toolCallsDesc.get.call(currentResponse);
|
||||
|
||||
log.info(`[TOOL CALL DEBUG] Getter returned: ${JSON.stringify(dynamicToolCalls)}`);
|
||||
|
||||
if (dynamicToolCalls && dynamicToolCalls.length > 0) {
|
||||
hasToolCalls = true;
|
||||
log.info(`Response has dynamic tool_calls with ${dynamicToolCalls.length} tools`);
|
||||
log.info(`Dynamic tool calls details: ${JSON.stringify(dynamicToolCalls)}`);
|
||||
// Ensure property is available for subsequent code
|
||||
currentResponse.tool_calls = dynamicToolCalls;
|
||||
log.info(`[TOOL CALL DEBUG] Updated currentResponse.tool_calls with dynamic values`);
|
||||
} else {
|
||||
log.info(`[TOOL CALL DEBUG] Getter returned no valid tool calls`);
|
||||
}
|
||||
} else {
|
||||
log.info(`[TOOL CALL DEBUG] No getter function found for tool_calls`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
log.error(`Error checking dynamic tool_calls: ${e}`);
|
||||
log.error(`[TOOL CALL DEBUG] Error details: ${e.stack || 'No stack trace'}`);
|
||||
}
|
||||
}
|
||||
|
||||
log.info(`Response has tool_calls: ${hasToolCalls ? 'true' : 'false'}`);
|
||||
if (hasToolCalls && currentResponse.tool_calls) {
|
||||
log.info(`[TOOL CALL DEBUG] Final tool_calls that will be used: ${JSON.stringify(currentResponse.tool_calls)}`);
|
||||
}
|
||||
|
||||
// Tool execution loop
|
||||
if (toolsEnabled && hasToolCalls && currentResponse.tool_calls) {
|
||||
log.info(`========== STAGE 6: TOOL EXECUTION ==========`);
|
||||
log.info(`Response contains ${currentResponse.tool_calls.length} tool calls, processing...`);
|
||||
|
||||
// Format tool calls for logging
|
||||
log.info(`========== TOOL CALL DETAILS ==========`);
|
||||
currentResponse.tool_calls.forEach((toolCall, idx) => {
|
||||
log.info(`Tool call ${idx + 1}: name=${toolCall.function?.name || 'unknown'}, id=${toolCall.id || 'no-id'}`);
|
||||
log.info(`Arguments: ${toolCall.function?.arguments || '{}'}`);
|
||||
});
|
||||
|
||||
// Keep track of whether we're in a streaming response
|
||||
const isStreaming = shouldEnableStream && streamCallback;
|
||||
let streamingPaused = false;
|
||||
|
||||
// If streaming was enabled, send an update to the user
|
||||
if (isStreaming && streamCallback) {
|
||||
streamingPaused = true;
|
||||
// Send a dedicated message with a specific type for tool execution
|
||||
streamCallback('', false, {
|
||||
text: '',
|
||||
done: false,
|
||||
toolExecution: {
|
||||
type: 'start',
|
||||
tool: {
|
||||
name: 'tool_execution',
|
||||
arguments: {}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
while (toolCallIterations < maxToolCallIterations) {
|
||||
toolCallIterations++;
|
||||
log.info(`========== TOOL ITERATION ${toolCallIterations}/${maxToolCallIterations} ==========`);
|
||||
|
||||
// Create a copy of messages before tool execution
|
||||
const previousMessages = [...currentMessages];
|
||||
|
||||
try {
|
||||
const toolCallingStartTime = Date.now();
|
||||
log.info(`========== PIPELINE TOOL EXECUTION FLOW ==========`);
|
||||
log.info(`About to call toolCalling.execute with ${currentResponse.tool_calls.length} tool calls`);
|
||||
log.info(`Tool calls being passed to stage: ${JSON.stringify(currentResponse.tool_calls)}`);
|
||||
|
||||
const toolCallingResult = await this.stages.toolCalling.execute({
|
||||
response: currentResponse,
|
||||
messages: currentMessages,
|
||||
options: modelSelection.options
|
||||
});
|
||||
this.updateStageMetrics('toolCalling', toolCallingStartTime);
|
||||
|
||||
log.info(`ToolCalling stage execution complete, got result with needsFollowUp: ${toolCallingResult.needsFollowUp}`);
|
||||
|
||||
// Update messages with tool results
|
||||
currentMessages = toolCallingResult.messages;
|
||||
|
||||
// Log the tool results for debugging
|
||||
const toolResultMessages = currentMessages.filter(
|
||||
msg => msg.role === 'tool' && !previousMessages.includes(msg)
|
||||
);
|
||||
|
||||
log.info(`========== TOOL EXECUTION RESULTS ==========`);
|
||||
log.info(`Received ${toolResultMessages.length} tool results`);
|
||||
toolResultMessages.forEach((msg, idx) => {
|
||||
log.info(`Tool result ${idx + 1}: tool_call_id=${msg.tool_call_id}, content=${msg.content}`);
|
||||
log.info(`Tool result status: ${msg.content.startsWith('Error:') ? 'ERROR' : 'SUCCESS'}`);
|
||||
log.info(`Tool result for: ${this.getToolNameFromToolCallId(currentMessages, msg.tool_call_id || '')}`);
|
||||
|
||||
// If streaming, show tool executions to the user
|
||||
if (isStreaming && streamCallback) {
|
||||
// For each tool result, format a readable message for the user
|
||||
const toolName = this.getToolNameFromToolCallId(currentMessages, msg.tool_call_id || '');
|
||||
|
||||
// Create a structured tool result message
|
||||
// The client will receive this structured data and can display it properly
|
||||
try {
|
||||
// Parse the result content if it's JSON
|
||||
let parsedContent = msg.content;
|
||||
try {
|
||||
// Check if the content is JSON
|
||||
if (msg.content.trim().startsWith('{') || msg.content.trim().startsWith('[')) {
|
||||
parsedContent = JSON.parse(msg.content);
|
||||
}
|
||||
} catch (e) {
|
||||
// If parsing fails, keep the original content
|
||||
log.info(`Could not parse tool result as JSON: ${e}`);
|
||||
}
|
||||
|
||||
// Send the structured tool result directly so the client has the raw data
|
||||
streamCallback('', false, {
|
||||
text: '',
|
||||
done: false,
|
||||
toolExecution: {
|
||||
type: 'complete',
|
||||
tool: {
|
||||
name: toolName,
|
||||
arguments: {}
|
||||
},
|
||||
result: parsedContent
|
||||
}
|
||||
});
|
||||
|
||||
// No longer need to send formatted text version
|
||||
// The client should use the structured data instead
|
||||
} catch (err) {
|
||||
log.error(`Error sending structured tool result: ${err}`);
|
||||
// Use structured format here too instead of falling back to text format
|
||||
streamCallback('', false, {
|
||||
text: '',
|
||||
done: false,
|
||||
toolExecution: {
|
||||
type: 'complete',
|
||||
tool: {
|
||||
name: toolName || 'unknown',
|
||||
arguments: {}
|
||||
},
|
||||
result: msg.content
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Check if we need another LLM completion for tool results
|
||||
if (toolCallingResult.needsFollowUp) {
|
||||
log.info(`========== TOOL FOLLOW-UP REQUIRED ==========`);
|
||||
log.info('Tool execution complete, sending results back to LLM');
|
||||
|
||||
// Ensure messages are properly formatted
|
||||
this.validateToolMessages(currentMessages);
|
||||
|
||||
// If streaming, show progress to the user
|
||||
if (isStreaming && streamCallback) {
|
||||
streamCallback('', false, {
|
||||
text: '',
|
||||
done: false,
|
||||
toolExecution: {
|
||||
type: 'update',
|
||||
tool: {
|
||||
name: 'tool_processing',
|
||||
arguments: {}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Extract tool execution status information for Ollama feedback
|
||||
let toolExecutionStatus;
|
||||
|
||||
if (currentResponse.provider === 'Ollama') {
|
||||
// Collect tool execution status from the tool results
|
||||
toolExecutionStatus = toolResultMessages.map(msg => {
|
||||
// Determine if this was a successful tool call
|
||||
const isError = msg.content.startsWith('Error:');
|
||||
return {
|
||||
toolCallId: msg.tool_call_id || '',
|
||||
name: msg.name || 'unknown',
|
||||
success: !isError,
|
||||
result: msg.content,
|
||||
error: isError ? msg.content.substring(7) : undefined
|
||||
};
|
||||
});
|
||||
|
||||
log.info(`Created tool execution status for Ollama: ${toolExecutionStatus.length} entries`);
|
||||
toolExecutionStatus.forEach((status, idx) => {
|
||||
log.info(`Tool status ${idx + 1}: ${status.name} - ${status.success ? 'success' : 'failed'}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Generate a new completion with the updated messages
|
||||
const followUpStartTime = Date.now();
|
||||
|
||||
// Log messages being sent to LLM for tool follow-up
|
||||
log.info(`========== SENDING TOOL RESULTS TO LLM FOR FOLLOW-UP ==========`);
|
||||
log.info(`Total messages being sent: ${currentMessages.length}`);
|
||||
// Log the most recent messages (last 3) for clarity
|
||||
const recentMessages = currentMessages.slice(-3);
|
||||
recentMessages.forEach((msg, idx) => {
|
||||
const position = currentMessages.length - recentMessages.length + idx;
|
||||
log.info(`Message ${position} (${msg.role}): ${msg.content?.substring(0, 100)}${msg.content?.length > 100 ? '...' : ''}`);
|
||||
if (msg.tool_calls) {
|
||||
log.info(` Has ${msg.tool_calls.length} tool calls`);
|
||||
}
|
||||
if (msg.tool_call_id) {
|
||||
log.info(` Tool call ID: ${msg.tool_call_id}`);
|
||||
}
|
||||
});
|
||||
|
||||
log.info(`LLM follow-up request options: ${JSON.stringify({
|
||||
model: modelSelection.options.model,
|
||||
enableTools: true,
|
||||
stream: modelSelection.options.stream,
|
||||
provider: currentResponse.provider
|
||||
})}`);
|
||||
|
||||
const followUpCompletion = await this.stages.llmCompletion.execute({
|
||||
messages: currentMessages,
|
||||
options: {
|
||||
...modelSelection.options,
|
||||
// Ensure tool support is still enabled for follow-up requests
|
||||
enableTools: true,
|
||||
// Preserve original streaming setting for tool execution follow-ups
|
||||
stream: modelSelection.options.stream,
|
||||
// Add tool execution status for Ollama provider
|
||||
...(currentResponse.provider === 'Ollama' ? { toolExecutionStatus } : {})
|
||||
}
|
||||
});
|
||||
this.updateStageMetrics('llmCompletion', followUpStartTime);
|
||||
|
||||
// Log the follow-up response from the LLM
|
||||
log.info(`========== LLM FOLLOW-UP RESPONSE RECEIVED ==========`);
|
||||
log.info(`Follow-up response model: ${followUpCompletion.response.model}, provider: ${followUpCompletion.response.provider}`);
|
||||
log.info(`Follow-up response text: ${followUpCompletion.response.text?.substring(0, 150)}${followUpCompletion.response.text?.length > 150 ? '...' : ''}`);
|
||||
log.info(`Follow-up contains tool calls: ${!!followUpCompletion.response.tool_calls && followUpCompletion.response.tool_calls.length > 0}`);
|
||||
if (followUpCompletion.response.tool_calls && followUpCompletion.response.tool_calls.length > 0) {
|
||||
log.info(`Follow-up has ${followUpCompletion.response.tool_calls.length} new tool calls`);
|
||||
}
|
||||
|
||||
// Update current response for the next iteration
|
||||
currentResponse = followUpCompletion.response;
|
||||
|
||||
// Check if we need to continue the tool calling loop
|
||||
if (!currentResponse.tool_calls || currentResponse.tool_calls.length === 0) {
|
||||
log.info(`========== TOOL EXECUTION COMPLETE ==========`);
|
||||
log.info('No more tool calls, breaking tool execution loop');
|
||||
break;
|
||||
} else {
|
||||
log.info(`========== ADDITIONAL TOOL CALLS DETECTED ==========`);
|
||||
log.info(`Next iteration has ${currentResponse.tool_calls.length} more tool calls`);
|
||||
// Log the next set of tool calls
|
||||
currentResponse.tool_calls.forEach((toolCall, idx) => {
|
||||
log.info(`Next tool call ${idx + 1}: name=${toolCall.function?.name || 'unknown'}, id=${toolCall.id || 'no-id'}`);
|
||||
log.info(`Arguments: ${toolCall.function?.arguments || '{}'}`);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
log.info(`========== TOOL EXECUTION COMPLETE ==========`);
|
||||
log.info('No follow-up needed, breaking tool execution loop');
|
||||
break;
|
||||
}
|
||||
} catch (error: any) {
|
||||
log.info(`========== TOOL EXECUTION ERROR ==========`);
|
||||
log.error(`Error in tool execution: ${error.message || String(error)}`);
|
||||
|
||||
// Add error message to the conversation if tool execution fails
|
||||
currentMessages.push({
|
||||
role: 'system',
|
||||
content: `Error executing tool: ${error.message || String(error)}. Please try a different approach.`
|
||||
});
|
||||
|
||||
// If streaming, show error to the user
|
||||
if (isStreaming && streamCallback) {
|
||||
streamCallback('', false, {
|
||||
text: '',
|
||||
done: false,
|
||||
toolExecution: {
|
||||
type: 'error',
|
||||
tool: {
|
||||
name: 'unknown',
|
||||
arguments: {}
|
||||
},
|
||||
result: error.message || 'unknown error'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// For Ollama, create tool execution status with the error
|
||||
let toolExecutionStatus;
|
||||
if (currentResponse.provider === 'Ollama' && currentResponse.tool_calls) {
|
||||
// We need to create error statuses for all tool calls that failed
|
||||
toolExecutionStatus = currentResponse.tool_calls.map(toolCall => {
|
||||
return {
|
||||
toolCallId: toolCall.id || '',
|
||||
name: toolCall.function?.name || 'unknown',
|
||||
success: false,
|
||||
result: `Error: ${error.message || 'unknown error'}`,
|
||||
error: error.message || 'unknown error'
|
||||
};
|
||||
});
|
||||
|
||||
log.info(`Created error tool execution status for Ollama: ${toolExecutionStatus.length} entries`);
|
||||
}
|
||||
|
||||
// Make a follow-up request to the LLM with the error information
|
||||
const errorFollowUpCompletion = await this.stages.llmCompletion.execute({
|
||||
messages: currentMessages,
|
||||
options: {
|
||||
...modelSelection.options,
|
||||
// Preserve streaming for error follow-up
|
||||
stream: modelSelection.options.stream,
|
||||
// For Ollama, include tool execution status
|
||||
...(currentResponse.provider === 'Ollama' ? { toolExecutionStatus } : {})
|
||||
}
|
||||
});
|
||||
|
||||
// Log the error follow-up response from the LLM
|
||||
log.info(`========== ERROR FOLLOW-UP RESPONSE RECEIVED ==========`);
|
||||
log.info(`Error follow-up response model: ${errorFollowUpCompletion.response.model}, provider: ${errorFollowUpCompletion.response.provider}`);
|
||||
log.info(`Error follow-up response text: ${errorFollowUpCompletion.response.text?.substring(0, 150)}${errorFollowUpCompletion.response.text?.length > 150 ? '...' : ''}`);
|
||||
log.info(`Error follow-up contains tool calls: ${!!errorFollowUpCompletion.response.tool_calls && errorFollowUpCompletion.response.tool_calls.length > 0}`);
|
||||
|
||||
// Update current response and break the tool loop
|
||||
currentResponse = errorFollowUpCompletion.response;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (toolCallIterations >= maxToolCallIterations) {
|
||||
log.info(`========== MAXIMUM TOOL ITERATIONS REACHED ==========`);
|
||||
log.error(`Reached maximum tool call iterations (${maxToolCallIterations}), terminating loop`);
|
||||
|
||||
// Add a message to inform the LLM that we've reached the limit
|
||||
currentMessages.push({
|
||||
role: 'system',
|
||||
content: `Maximum tool call iterations (${maxToolCallIterations}) reached. Please provide your best response with the information gathered so far.`
|
||||
});
|
||||
|
||||
// If streaming, inform the user about iteration limit
|
||||
if (isStreaming && streamCallback) {
|
||||
streamCallback(`[Reached maximum of ${maxToolCallIterations} tool calls. Finalizing response...]\n\n`, false);
|
||||
}
|
||||
|
||||
// For Ollama, create a status about reaching max iterations
|
||||
let toolExecutionStatus;
|
||||
if (currentResponse.provider === 'Ollama' && currentResponse.tool_calls) {
|
||||
// Create a special status message about max iterations
|
||||
toolExecutionStatus = [
|
||||
{
|
||||
toolCallId: 'max-iterations',
|
||||
name: 'system',
|
||||
success: false,
|
||||
result: `Maximum tool call iterations (${maxToolCallIterations}) reached.`,
|
||||
error: `Reached the maximum number of allowed tool calls (${maxToolCallIterations}). Please provide a final response with the information gathered so far.`
|
||||
}
|
||||
];
|
||||
|
||||
log.info(`Created max iterations status for Ollama`);
|
||||
}
|
||||
|
||||
// Make a final request to get a summary response
|
||||
const finalFollowUpCompletion = await this.stages.llmCompletion.execute({
|
||||
messages: currentMessages,
|
||||
options: {
|
||||
...modelSelection.options,
|
||||
enableTools: false, // Disable tools for the final response
|
||||
// Preserve streaming setting for max iterations response
|
||||
stream: modelSelection.options.stream,
|
||||
// For Ollama, include tool execution status
|
||||
...(currentResponse.provider === 'Ollama' ? { toolExecutionStatus } : {})
|
||||
}
|
||||
});
|
||||
|
||||
// Update the current response
|
||||
currentResponse = finalFollowUpCompletion.response;
|
||||
}
|
||||
|
||||
// If streaming was paused for tool execution, resume it now with the final response
|
||||
if (isStreaming && streamCallback && streamingPaused) {
|
||||
// First log for debugging
|
||||
const responseText = currentResponse.text || "";
|
||||
log.info(`Resuming streaming with final response: ${responseText.length} chars`);
|
||||
|
||||
if (responseText.length > 0 && !hasStreamedContent) {
|
||||
// Resume streaming with the final response text only if we haven't already streamed content
|
||||
// This is where we send the definitive done:true signal with the complete content
|
||||
streamCallback(responseText, true);
|
||||
log.info(`Sent final response with done=true signal and text content`);
|
||||
} else if (hasStreamedContent) {
|
||||
log.info(`Content already streamed, sending done=true signal only after tool execution`);
|
||||
// Just send the done signal without duplicating content
|
||||
streamCallback('', true);
|
||||
} else {
|
||||
// For Anthropic, sometimes text is empty but response is in stream
|
||||
if ((currentResponse.provider === 'Anthropic' || currentResponse.provider === 'OpenAI') && currentResponse.stream) {
|
||||
log.info(`Detected empty response text for ${currentResponse.provider} provider with stream, sending stream content directly`);
|
||||
// For Anthropic/OpenAI with stream mode, we need to stream the final response
|
||||
if (currentResponse.stream) {
|
||||
await currentResponse.stream(async (chunk: StreamChunk) => {
|
||||
// Process the chunk
|
||||
const processedChunk = await this.processStreamChunk(chunk, input.options);
|
||||
|
||||
// Forward to callback
|
||||
streamCallback(
|
||||
processedChunk.text,
|
||||
processedChunk.done || chunk.done || false,
|
||||
chunk
|
||||
);
|
||||
});
|
||||
log.info(`Completed streaming final ${currentResponse.provider} response after tool execution`);
|
||||
}
|
||||
} else {
|
||||
// Empty response with done=true as fallback
|
||||
streamCallback('', true);
|
||||
log.info(`Sent empty final response with done=true signal`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (toolsEnabled) {
|
||||
log.info(`========== NO TOOL CALLS DETECTED ==========`);
|
||||
log.info(`LLM response did not contain any tool calls, skipping tool execution`);
|
||||
|
||||
// Handle streaming for responses without tool calls
|
||||
if (shouldEnableStream && streamCallback && !hasStreamedContent) {
|
||||
log.info(`Sending final streaming response without tool calls: ${currentResponse.text.length} chars`);
|
||||
|
||||
// Send the final response with done=true to complete the streaming
|
||||
streamCallback(currentResponse.text, true);
|
||||
|
||||
log.info(`Sent final non-tool response with done=true signal`);
|
||||
} else if (shouldEnableStream && streamCallback && hasStreamedContent) {
|
||||
log.info(`Content already streamed, sending done=true signal only`);
|
||||
// Just send the done signal without duplicating content
|
||||
streamCallback('', true);
|
||||
}
|
||||
}
|
||||
const toolLoopResult = await toolLoop.run({
|
||||
messages: preparedMessages.messages,
|
||||
userQuery,
|
||||
providerName,
|
||||
options: modelSelection.options,
|
||||
chunkOptions: input.options,
|
||||
streamCallback,
|
||||
shouldEnableStream,
|
||||
providerStream,
|
||||
toolsEnabled: modelSelection.options.enableTools !== false,
|
||||
maxToolCallIterations: this.config.maxToolCallIterations,
|
||||
accumulatedText
|
||||
});
|
||||
|
||||
let currentResponse = toolLoopResult.response;
|
||||
|
||||
// Process the final response
|
||||
log.info(`========== FINAL RESPONSE PROCESSING ==========`);
|
||||
@ -857,39 +335,6 @@ export class ChatPipeline {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a stream chunk through the response processing stage
|
||||
*/
|
||||
private async processStreamChunk(chunk: StreamChunk, options?: any): Promise<StreamChunk> {
|
||||
try {
|
||||
// Only process non-empty chunks
|
||||
if (!chunk.text) return chunk;
|
||||
|
||||
// Create a minimal response object for the processor
|
||||
const miniResponse = {
|
||||
text: chunk.text,
|
||||
model: 'streaming',
|
||||
provider: 'streaming'
|
||||
};
|
||||
|
||||
// Process the chunk text
|
||||
const processed = await this.stages.responseProcessing.execute({
|
||||
response: miniResponse,
|
||||
options: options
|
||||
});
|
||||
|
||||
// Return processed chunk
|
||||
return {
|
||||
...chunk,
|
||||
text: processed.text
|
||||
};
|
||||
} catch (error) {
|
||||
// On error, return original chunk
|
||||
log.error(`Error processing stream chunk: ${error}`);
|
||||
return chunk;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update metrics for a pipeline stage
|
||||
*/
|
||||
@ -933,51 +378,4 @@ export class ChatPipeline {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find tool name from tool call ID by looking at previous assistant messages
|
||||
*/
|
||||
private getToolNameFromToolCallId(messages: Message[], toolCallId: string): string {
|
||||
if (!toolCallId) return 'unknown';
|
||||
|
||||
// Look for assistant messages with tool_calls
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const message = messages[i];
|
||||
if (message.role === 'assistant' && message.tool_calls) {
|
||||
// Find the tool call with the matching ID
|
||||
const toolCall = message.tool_calls.find(tc => tc.id === toolCallId);
|
||||
if (toolCall && toolCall.function && toolCall.function.name) {
|
||||
return toolCall.function.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate tool messages to ensure they're properly formatted
|
||||
*/
|
||||
private validateToolMessages(messages: Message[]): void {
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const message = messages[i];
|
||||
|
||||
// Ensure tool messages have required fields
|
||||
if (message.role === 'tool') {
|
||||
if (!message.tool_call_id) {
|
||||
log.info(`Tool message missing tool_call_id, adding placeholder`);
|
||||
message.tool_call_id = `tool_${i}`;
|
||||
}
|
||||
|
||||
// Content should be a string
|
||||
if (typeof message.content !== 'string') {
|
||||
log.info(`Tool message content is not a string, converting`);
|
||||
try {
|
||||
message.content = JSON.stringify(message.content);
|
||||
} catch (e) {
|
||||
message.content = String(message.content);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,36 @@
|
||||
import type { ChatCompletionOptions, NormalizedChatResponse, StreamChunk } from '../ai_interface.js';
|
||||
import type { ToolLoopStages } from './chat_pipeline_tool_loop.js';
|
||||
import log from '../../log.js';
|
||||
|
||||
export const processStreamChunk: (
|
||||
stages: ToolLoopStages,
|
||||
chunk: StreamChunk,
|
||||
options?: ChatCompletionOptions
|
||||
) => Promise<StreamChunk> = async (stages, chunk, options) => {
|
||||
try {
|
||||
if (!chunk.text) {
|
||||
return chunk;
|
||||
}
|
||||
|
||||
const miniResponse: NormalizedChatResponse = {
|
||||
text: chunk.text,
|
||||
model: 'streaming',
|
||||
provider: 'streaming',
|
||||
tool_calls: []
|
||||
};
|
||||
|
||||
const processed = await stages.responseProcessing.execute({
|
||||
response: miniResponse,
|
||||
options: options ?? { enableTools: false }
|
||||
});
|
||||
|
||||
return {
|
||||
...chunk,
|
||||
text: processed.text
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
log.error(`Error processing stream chunk: ${errorMessage}`);
|
||||
return chunk;
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,477 @@
|
||||
import type { ChatCompletionOptions, Message, NormalizedChatResponse, StreamChunk } from '../ai_interface.js';
|
||||
import type { StreamCallback } from './interfaces.js';
|
||||
import type { StreamingStrategy } from './streaming/streaming_strategy.js';
|
||||
import type { ToolLoopStages } from './chat_pipeline_tool_loop.js';
|
||||
import log from '../../log.js';
|
||||
import {
|
||||
buildFallbackResponseFromToolResults,
|
||||
getToolNameFromToolCallId,
|
||||
validateToolMessages
|
||||
} from './chat_pipeline_tool_helpers.js';
|
||||
import { processStreamChunk } from './chat_pipeline_stream_helpers.js';
|
||||
|
||||
export interface ToolExecutionLoopDependencies {
|
||||
stages: ToolLoopStages;
|
||||
streamingStrategy: StreamingStrategy;
|
||||
updateStageMetrics: (stageName: string, startTime: number) => void;
|
||||
}
|
||||
|
||||
export interface ToolExecutionLoopInput {
|
||||
response: NormalizedChatResponse;
|
||||
messages: Message[];
|
||||
options: ChatCompletionOptions;
|
||||
chunkOptions?: ChatCompletionOptions;
|
||||
streamCallback?: StreamCallback;
|
||||
shouldEnableStream: boolean;
|
||||
toolsEnabled: boolean;
|
||||
hasToolCalls: boolean;
|
||||
maxToolCallIterations: number;
|
||||
accumulatedText: string;
|
||||
hasStreamedContent: boolean;
|
||||
}
|
||||
|
||||
export interface ToolExecutionLoopResult {
|
||||
response: NormalizedChatResponse;
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
export const executeToolExecutionLoop: (
|
||||
dependencies: ToolExecutionLoopDependencies,
|
||||
input: ToolExecutionLoopInput
|
||||
) => Promise<ToolExecutionLoopResult> = async (dependencies, input) => {
|
||||
const {
|
||||
response,
|
||||
messages,
|
||||
options,
|
||||
chunkOptions,
|
||||
streamCallback,
|
||||
shouldEnableStream,
|
||||
toolsEnabled,
|
||||
hasToolCalls,
|
||||
maxToolCallIterations,
|
||||
accumulatedText,
|
||||
hasStreamedContent
|
||||
} = input;
|
||||
|
||||
const { stages, streamingStrategy, updateStageMetrics } = dependencies;
|
||||
|
||||
let currentResponse = response;
|
||||
let currentMessages = messages;
|
||||
let toolCallIterations = 0;
|
||||
|
||||
if (toolsEnabled && hasToolCalls && currentResponse.tool_calls) {
|
||||
log.info(`========== STAGE 6: TOOL EXECUTION ==========`);
|
||||
log.info(`Response contains ${currentResponse.tool_calls.length} tool calls, processing...`);
|
||||
|
||||
log.info(`========== TOOL CALL DETAILS ==========`);
|
||||
currentResponse.tool_calls.forEach((toolCall, idx) => {
|
||||
log.info(`Tool call ${idx + 1}: name=${toolCall.function?.name || 'unknown'}, id=${toolCall.id || 'no-id'}`);
|
||||
log.info(`Arguments: ${toolCall.function?.arguments || '{}'}`);
|
||||
});
|
||||
|
||||
const isStreaming = shouldEnableStream && streamCallback;
|
||||
let streamingPaused = false;
|
||||
|
||||
if (isStreaming && streamCallback) {
|
||||
streamingPaused = true;
|
||||
streamCallback('', false, {
|
||||
text: '',
|
||||
done: false,
|
||||
toolExecution: {
|
||||
type: 'start',
|
||||
tool: {
|
||||
name: 'tool_execution',
|
||||
arguments: {}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
while (toolCallIterations < maxToolCallIterations) {
|
||||
toolCallIterations++;
|
||||
log.info(`========== TOOL ITERATION ${toolCallIterations}/${maxToolCallIterations} ==========`);
|
||||
|
||||
const previousMessages = [...currentMessages];
|
||||
|
||||
try {
|
||||
const toolCallingStartTime = Date.now();
|
||||
log.info(`========== PIPELINE TOOL EXECUTION FLOW ==========`);
|
||||
log.info(`About to call toolCalling.execute with ${currentResponse.tool_calls.length} tool calls`);
|
||||
log.info(`Tool calls being passed to stage: ${JSON.stringify(currentResponse.tool_calls)}`);
|
||||
|
||||
const toolCallingResult = await stages.toolCalling.execute({
|
||||
response: currentResponse,
|
||||
messages: currentMessages,
|
||||
options
|
||||
});
|
||||
updateStageMetrics('toolCalling', toolCallingStartTime);
|
||||
|
||||
log.info(`ToolCalling stage execution complete, got result with needsFollowUp: ${toolCallingResult.needsFollowUp}`);
|
||||
|
||||
currentMessages = toolCallingResult.messages;
|
||||
|
||||
const toolResultMessages = currentMessages.filter(
|
||||
msg => msg.role === 'tool' && !previousMessages.includes(msg)
|
||||
);
|
||||
|
||||
log.info(`========== TOOL EXECUTION RESULTS ==========`);
|
||||
log.info(`Received ${toolResultMessages.length} tool results`);
|
||||
toolResultMessages.forEach((msg, idx) => {
|
||||
log.info(`Tool result ${idx + 1}: tool_call_id=${msg.tool_call_id}, content=${msg.content}`);
|
||||
log.info(`Tool result status: ${msg.content.startsWith('Error:') ? 'ERROR' : 'SUCCESS'}`);
|
||||
log.info(`Tool result for: ${getToolNameFromToolCallId(currentMessages, msg.tool_call_id || '')}`);
|
||||
|
||||
if (isStreaming && streamCallback) {
|
||||
const toolName = getToolNameFromToolCallId(currentMessages, msg.tool_call_id || '');
|
||||
|
||||
try {
|
||||
let parsedContent = msg.content;
|
||||
try {
|
||||
if (msg.content.trim().startsWith('{') || msg.content.trim().startsWith('[')) {
|
||||
parsedContent = JSON.parse(msg.content);
|
||||
}
|
||||
} catch (error) {
|
||||
log.info(`Could not parse tool result as JSON: ${error}`);
|
||||
}
|
||||
|
||||
streamCallback('', false, {
|
||||
text: '',
|
||||
done: false,
|
||||
toolExecution: {
|
||||
type: 'complete',
|
||||
tool: {
|
||||
name: toolName,
|
||||
arguments: {}
|
||||
},
|
||||
result: parsedContent
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
log.error(`Error sending structured tool result: ${err}`);
|
||||
streamCallback('', false, {
|
||||
text: '',
|
||||
done: false,
|
||||
toolExecution: {
|
||||
type: 'complete',
|
||||
tool: {
|
||||
name: toolName || 'unknown',
|
||||
arguments: {}
|
||||
},
|
||||
result: msg.content
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (toolCallingResult.needsFollowUp) {
|
||||
log.info(`========== TOOL FOLLOW-UP REQUIRED ==========`);
|
||||
log.info('Tool execution complete, sending results back to LLM');
|
||||
|
||||
validateToolMessages(currentMessages);
|
||||
|
||||
if (isStreaming && streamCallback) {
|
||||
streamCallback('', false, {
|
||||
text: '',
|
||||
done: false,
|
||||
toolExecution: {
|
||||
type: 'update',
|
||||
tool: {
|
||||
name: 'tool_processing',
|
||||
arguments: {}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let toolExecutionStatus;
|
||||
if (currentResponse.provider === 'Ollama') {
|
||||
toolExecutionStatus = toolResultMessages.map(msg => {
|
||||
const isError = msg.content.startsWith('Error:');
|
||||
return {
|
||||
toolCallId: msg.tool_call_id || '',
|
||||
name: msg.name || 'unknown',
|
||||
success: !isError,
|
||||
result: msg.content,
|
||||
error: isError ? msg.content.substring(7) : undefined
|
||||
};
|
||||
});
|
||||
|
||||
log.info(`Created tool execution status for Ollama: ${toolExecutionStatus.length} entries`);
|
||||
toolExecutionStatus.forEach((status, idx) => {
|
||||
log.info(`Tool status ${idx + 1}: ${status.name} - ${status.success ? 'success' : 'failed'}`);
|
||||
});
|
||||
}
|
||||
|
||||
const followUpStartTime = Date.now();
|
||||
|
||||
log.info(`========== SENDING TOOL RESULTS TO LLM FOR FOLLOW-UP ==========`);
|
||||
log.info(`Total messages being sent: ${currentMessages.length}`);
|
||||
const recentMessages = currentMessages.slice(-3);
|
||||
recentMessages.forEach((msg, idx) => {
|
||||
const position = currentMessages.length - recentMessages.length + idx;
|
||||
log.info(`Message ${position} (${msg.role}): ${msg.content?.substring(0, 100)}${msg.content?.length > 100 ? '...' : ''}`);
|
||||
if (msg.tool_calls) {
|
||||
log.info(` Has ${msg.tool_calls.length} tool calls`);
|
||||
}
|
||||
if (msg.tool_call_id) {
|
||||
log.info(` Tool call ID: ${msg.tool_call_id}`);
|
||||
}
|
||||
});
|
||||
|
||||
const followUpStream = streamingStrategy.resolveFollowUpStreaming({
|
||||
kind: 'tool',
|
||||
hasStreamCallback: !!streamCallback,
|
||||
providerName: currentResponse.provider,
|
||||
toolsEnabled: true
|
||||
});
|
||||
|
||||
log.info(`LLM follow-up request options: ${JSON.stringify({
|
||||
model: options.model,
|
||||
enableTools: true,
|
||||
stream: followUpStream,
|
||||
provider: currentResponse.provider
|
||||
})}`);
|
||||
|
||||
const followUpCompletion = await stages.llmCompletion.execute({
|
||||
messages: currentMessages,
|
||||
options: {
|
||||
...options,
|
||||
enableTools: true,
|
||||
stream: followUpStream,
|
||||
...(currentResponse.provider === 'Ollama' ? { toolExecutionStatus } : {})
|
||||
}
|
||||
});
|
||||
updateStageMetrics('llmCompletion', followUpStartTime);
|
||||
|
||||
log.info(`========== LLM FOLLOW-UP RESPONSE RECEIVED ==========`);
|
||||
log.info(`Follow-up response model: ${followUpCompletion.response.model}, provider: ${followUpCompletion.response.provider}`);
|
||||
log.info(`Follow-up response text: ${followUpCompletion.response.text?.substring(0, 150)}${followUpCompletion.response.text?.length > 150 ? '...' : ''}`);
|
||||
log.info(`Follow-up contains tool calls: ${followUpCompletion.response.tool_calls.length > 0}`);
|
||||
if (followUpCompletion.response.tool_calls.length > 0) {
|
||||
log.info(`Follow-up has ${followUpCompletion.response.tool_calls.length} new tool calls`);
|
||||
}
|
||||
|
||||
currentResponse = followUpCompletion.response;
|
||||
|
||||
if (currentResponse.tool_calls.length === 0) {
|
||||
log.info(`========== TOOL EXECUTION COMPLETE ==========`);
|
||||
log.info('No more tool calls, breaking tool execution loop');
|
||||
break;
|
||||
} else {
|
||||
log.info(`========== ADDITIONAL TOOL CALLS DETECTED ==========`);
|
||||
log.info(`Next iteration has ${currentResponse.tool_calls.length} more tool calls`);
|
||||
currentResponse.tool_calls.forEach((toolCall, idx) => {
|
||||
log.info(`Next tool call ${idx + 1}: name=${toolCall.function?.name || 'unknown'}, id=${toolCall.id || 'no-id'}`);
|
||||
log.info(`Arguments: ${toolCall.function?.arguments || '{}'}`);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
log.info(`========== TOOL EXECUTION COMPLETE ==========`);
|
||||
log.info('No follow-up needed, breaking tool execution loop');
|
||||
break;
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
log.info(`========== TOOL EXECUTION ERROR ==========`);
|
||||
log.error(`Error in tool execution: ${errorMessage}`);
|
||||
|
||||
currentMessages.push({
|
||||
role: 'system',
|
||||
content: `Error executing tool: ${errorMessage}. Please try a different approach.`
|
||||
});
|
||||
|
||||
if (isStreaming && streamCallback) {
|
||||
streamCallback('', false, {
|
||||
text: '',
|
||||
done: false,
|
||||
toolExecution: {
|
||||
type: 'error',
|
||||
tool: {
|
||||
name: 'unknown',
|
||||
arguments: {}
|
||||
},
|
||||
result: errorMessage || 'unknown error'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let toolExecutionStatus;
|
||||
if (currentResponse.provider === 'Ollama' && currentResponse.tool_calls) {
|
||||
toolExecutionStatus = currentResponse.tool_calls.map(toolCall => {
|
||||
return {
|
||||
toolCallId: toolCall.id || '',
|
||||
name: toolCall.function?.name || 'unknown',
|
||||
success: false,
|
||||
result: `Error: ${errorMessage || 'unknown error'}`,
|
||||
error: errorMessage || 'unknown error'
|
||||
};
|
||||
});
|
||||
|
||||
log.info(`Created error tool execution status for Ollama: ${toolExecutionStatus.length} entries`);
|
||||
}
|
||||
|
||||
const errorFollowUpStream = streamingStrategy.resolveFollowUpStreaming({
|
||||
kind: 'error',
|
||||
hasStreamCallback: !!streamCallback,
|
||||
providerName: currentResponse.provider,
|
||||
toolsEnabled: false
|
||||
});
|
||||
|
||||
const errorFollowUpCompletion = await stages.llmCompletion.execute({
|
||||
messages: currentMessages,
|
||||
options: {
|
||||
...options,
|
||||
enableTools: false,
|
||||
stream: errorFollowUpStream,
|
||||
...(currentResponse.provider === 'Ollama' ? { toolExecutionStatus } : {})
|
||||
}
|
||||
});
|
||||
|
||||
log.info(`========== ERROR FOLLOW-UP RESPONSE RECEIVED ==========`);
|
||||
log.info(`Error follow-up response model: ${errorFollowUpCompletion.response.model}, provider: ${errorFollowUpCompletion.response.provider}`);
|
||||
log.info(`Error follow-up response text: ${errorFollowUpCompletion.response.text?.substring(0, 150)}${errorFollowUpCompletion.response.text?.length > 150 ? '...' : ''}`);
|
||||
log.info(`Error follow-up contains tool calls: ${errorFollowUpCompletion.response.tool_calls.length > 0}`);
|
||||
|
||||
currentResponse = errorFollowUpCompletion.response;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (toolCallIterations >= maxToolCallIterations) {
|
||||
log.info(`========== MAXIMUM TOOL ITERATIONS REACHED ==========`);
|
||||
log.error(`Reached maximum tool call iterations (${maxToolCallIterations}), terminating loop`);
|
||||
|
||||
currentMessages.push({
|
||||
role: 'system',
|
||||
content: `Maximum tool call iterations (${maxToolCallIterations}) reached. Please provide your best response with the information gathered so far.`
|
||||
});
|
||||
|
||||
if (isStreaming && streamCallback) {
|
||||
streamCallback(`[Reached maximum of ${maxToolCallIterations} tool calls. Finalizing response...]\n\n`, false);
|
||||
}
|
||||
|
||||
let toolExecutionStatus;
|
||||
if (currentResponse.provider === 'Ollama' && currentResponse.tool_calls) {
|
||||
toolExecutionStatus = [
|
||||
{
|
||||
toolCallId: 'max-iterations',
|
||||
name: 'system',
|
||||
success: false,
|
||||
result: `Maximum tool call iterations (${maxToolCallIterations}) reached.`,
|
||||
error: `Reached the maximum number of allowed tool calls (${maxToolCallIterations}). Please provide a final response with the information gathered so far.`
|
||||
}
|
||||
];
|
||||
|
||||
log.info(`Created max iterations status for Ollama`);
|
||||
}
|
||||
|
||||
const maxIterationsStream = streamingStrategy.resolveFollowUpStreaming({
|
||||
kind: 'max_iterations',
|
||||
hasStreamCallback: !!streamCallback,
|
||||
providerName: currentResponse.provider,
|
||||
toolsEnabled: false
|
||||
});
|
||||
|
||||
const finalFollowUpCompletion = await stages.llmCompletion.execute({
|
||||
messages: currentMessages,
|
||||
options: {
|
||||
...options,
|
||||
enableTools: false,
|
||||
stream: maxIterationsStream,
|
||||
...(currentResponse.provider === 'Ollama' ? { toolExecutionStatus } : {})
|
||||
}
|
||||
});
|
||||
|
||||
currentResponse = finalFollowUpCompletion.response;
|
||||
}
|
||||
|
||||
if (!currentResponse.text || currentResponse.text.trim().length === 0) {
|
||||
log.info(`Final response text empty after tool execution. Requesting non-streaming summary.`);
|
||||
try {
|
||||
const finalTextStream = streamingStrategy.resolveFollowUpStreaming({
|
||||
kind: 'final_text',
|
||||
hasStreamCallback: !!streamCallback,
|
||||
providerName: currentResponse.provider,
|
||||
toolsEnabled: false
|
||||
});
|
||||
|
||||
const finalTextCompletion = await stages.llmCompletion.execute({
|
||||
messages: currentMessages,
|
||||
options: {
|
||||
...options,
|
||||
enableTools: false,
|
||||
stream: finalTextStream
|
||||
}
|
||||
});
|
||||
|
||||
currentResponse = finalTextCompletion.response;
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
log.error(`Error generating final response text: ${errorMessage}`);
|
||||
}
|
||||
|
||||
if (!currentResponse.text || currentResponse.text.trim().length === 0) {
|
||||
currentResponse.text = buildFallbackResponseFromToolResults(currentMessages);
|
||||
}
|
||||
}
|
||||
|
||||
if (isStreaming && streamCallback && streamingPaused) {
|
||||
const responseText = currentResponse.text || "";
|
||||
log.info(`Resuming streaming with final response: ${responseText.length} chars`);
|
||||
|
||||
const finalText = accumulatedText && responseText.startsWith(accumulatedText)
|
||||
? responseText.slice(accumulatedText.length)
|
||||
: responseText;
|
||||
|
||||
if (finalText.length > 0) {
|
||||
streamCallback(finalText, true);
|
||||
log.info(`Sent final response with done=true signal and ${finalText.length} chars`);
|
||||
} else {
|
||||
if ((currentResponse.provider === 'Anthropic' || currentResponse.provider === 'OpenAI') && currentResponse.stream) {
|
||||
log.info(`Detected empty response text for ${currentResponse.provider} provider with stream, sending stream content directly`);
|
||||
await currentResponse.stream(async (chunk: StreamChunk) => {
|
||||
const processedChunk = await processStreamChunk(stages, chunk, chunkOptions ?? options);
|
||||
streamCallback(
|
||||
processedChunk.text,
|
||||
processedChunk.done || chunk.done || false,
|
||||
chunk
|
||||
);
|
||||
});
|
||||
log.info(`Completed streaming final ${currentResponse.provider} response after tool execution`);
|
||||
} else if (currentResponse.stream) {
|
||||
log.info(`Streaming final response content for provider ${currentResponse.provider || 'unknown'}`);
|
||||
await currentResponse.stream(async (chunk: StreamChunk) => {
|
||||
const processedChunk = await processStreamChunk(stages, chunk, chunkOptions ?? options);
|
||||
streamCallback(
|
||||
processedChunk.text,
|
||||
processedChunk.done || chunk.done || false,
|
||||
chunk
|
||||
);
|
||||
});
|
||||
log.info(`Completed streaming final response after tool execution`);
|
||||
} else {
|
||||
streamCallback('', true);
|
||||
log.info(`Sent empty final response with done=true signal`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (toolsEnabled) {
|
||||
log.info(`========== NO TOOL CALLS DETECTED ==========`);
|
||||
log.info(`LLM response did not contain any tool calls, skipping tool execution`);
|
||||
|
||||
if (shouldEnableStream && streamCallback && !hasStreamedContent) {
|
||||
log.info(`Sending final streaming response without tool calls: ${currentResponse.text.length} chars`);
|
||||
streamCallback(currentResponse.text, true);
|
||||
log.info(`Sent final non-tool response with done=true signal`);
|
||||
} else if (shouldEnableStream && streamCallback && hasStreamedContent) {
|
||||
log.info(`Content already streamed, sending done=true signal only`);
|
||||
streamCallback('', true);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
response: currentResponse,
|
||||
messages: currentMessages
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,120 @@
|
||||
import type { Message } from '../ai_interface.js';
|
||||
import type { ToolCall } from '../tools/tool_interfaces.js';
|
||||
|
||||
export type FallbackToolName = 'list_notes' | 'search_notes' | 'keyword_search_notes';
|
||||
|
||||
export interface FallbackToolDecision {
|
||||
name: FallbackToolName;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export const getFallbackToolForQuery: (query: string) => FallbackToolDecision | null = (query) => {
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = trimmed.toLowerCase();
|
||||
const listHints = ['list', 'show', 'what notes', 'all notes', 'catalog', 'index'];
|
||||
const keywordHints = ['#', 'attribute', 'label', 'relation', '='];
|
||||
|
||||
if (listHints.some(hint => normalized.includes(hint))) {
|
||||
return { name: 'list_notes', reason: 'list query' };
|
||||
}
|
||||
|
||||
if (keywordHints.some(hint => normalized.includes(hint))) {
|
||||
return { name: 'keyword_search_notes', reason: 'keyword query' };
|
||||
}
|
||||
|
||||
return { name: 'search_notes', reason: 'general query' };
|
||||
};
|
||||
|
||||
export const buildForcedToolCallInstruction: (toolName: FallbackToolName, query: string) => string = (toolName, query) => {
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) {
|
||||
return `You must call the ${toolName} tool now. Respond only with a tool call.`;
|
||||
}
|
||||
|
||||
return `You must call the ${toolName} tool now to gather note data for: "${trimmed}". Respond only with a tool call.`;
|
||||
};
|
||||
|
||||
export const buildSyntheticToolCalls: (toolName: FallbackToolName, query: string) => ToolCall[] = (toolName, query) => {
|
||||
const trimmed = query.trim();
|
||||
const args: Record<string, unknown> = {};
|
||||
|
||||
if (toolName === 'search_notes' || toolName === 'keyword_search_notes') {
|
||||
if (trimmed) {
|
||||
args.query = trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: `forced-${Date.now()}`,
|
||||
type: 'function',
|
||||
function: {
|
||||
name: toolName,
|
||||
arguments: JSON.stringify(args)
|
||||
}
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
export const buildFallbackResponseFromToolResults: (messages: Message[]) => string = (messages) => {
|
||||
const toolMessages = messages.filter(message => message.role === 'tool').slice(-3);
|
||||
|
||||
if (toolMessages.length === 0) {
|
||||
return 'Tool execution completed, but no final response was generated. Please retry or adjust the request.';
|
||||
}
|
||||
|
||||
const resultLines = toolMessages.map(toolMessage => {
|
||||
const toolName = toolMessage.name || 'tool';
|
||||
const content = typeof toolMessage.content === 'string' ? toolMessage.content : String(toolMessage.content);
|
||||
const preview = content.length > 500 ? `${content.slice(0, 500)}...` : content;
|
||||
return `- ${toolName}: ${preview}`;
|
||||
});
|
||||
|
||||
return [
|
||||
'Tool execution completed, but the model returned no final text.',
|
||||
'Latest tool results:',
|
||||
...resultLines
|
||||
].join('\n');
|
||||
};
|
||||
|
||||
export const getToolNameFromToolCallId: (messages: Message[], toolCallId: string) => string = (messages, toolCallId) => {
|
||||
if (!toolCallId) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const message = messages[i];
|
||||
if (message.role === 'assistant' && message.tool_calls) {
|
||||
const toolCall = message.tool_calls.find(tc => tc.id === toolCallId);
|
||||
if (toolCall && toolCall.function && toolCall.function.name) {
|
||||
return toolCall.function.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
};
|
||||
|
||||
export const validateToolMessages: (messages: Message[]) => void = (messages) => {
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const message = messages[i];
|
||||
|
||||
if (message.role === 'tool') {
|
||||
if (!message.tool_call_id) {
|
||||
message.tool_call_id = `tool_${i}`;
|
||||
}
|
||||
|
||||
if (typeof message.content !== 'string') {
|
||||
try {
|
||||
message.content = JSON.stringify(message.content);
|
||||
} catch (error) {
|
||||
message.content = String(message.content);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
291
apps/server/src/services/llm/pipeline/chat_pipeline_tool_loop.ts
Normal file
291
apps/server/src/services/llm/pipeline/chat_pipeline_tool_loop.ts
Normal file
@ -0,0 +1,291 @@
|
||||
import type { ChatCompletionOptions, Message, NormalizedChatResponse, StreamChunk } from '../ai_interface.js';
|
||||
import type { LLMCompletionInput, ResponseProcessingInput, StreamCallback, ToolExecutionInput } from './interfaces.js';
|
||||
import type { StreamingStrategy } from './streaming/streaming_strategy.js';
|
||||
import log from '../../log.js';
|
||||
import {
|
||||
buildForcedToolCallInstruction,
|
||||
buildSyntheticToolCalls,
|
||||
getFallbackToolForQuery
|
||||
} from './chat_pipeline_tool_helpers.js';
|
||||
import { executeToolExecutionLoop } from './chat_pipeline_tool_execution.js';
|
||||
|
||||
export interface ToolLoopStages {
|
||||
llmCompletion: {
|
||||
execute: (input: LLMCompletionInput) => Promise<{ response: NormalizedChatResponse }>;
|
||||
};
|
||||
toolCalling: {
|
||||
execute: (input: ToolExecutionInput) => Promise<{ response: NormalizedChatResponse; needsFollowUp: boolean; messages: Message[] }>;
|
||||
};
|
||||
responseProcessing: {
|
||||
execute: (input: ResponseProcessingInput) => Promise<{ text: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ToolLoopDependencies {
|
||||
stages: ToolLoopStages;
|
||||
streamingStrategy: StreamingStrategy;
|
||||
updateStageMetrics: (stageName: string, startTime: number) => void;
|
||||
}
|
||||
|
||||
export interface ToolLoopInput {
|
||||
messages: Message[];
|
||||
userQuery: string;
|
||||
providerName?: string;
|
||||
options: ChatCompletionOptions;
|
||||
chunkOptions?: ChatCompletionOptions;
|
||||
streamCallback?: StreamCallback;
|
||||
shouldEnableStream: boolean;
|
||||
providerStream: boolean;
|
||||
toolsEnabled: boolean;
|
||||
maxToolCallIterations: number;
|
||||
accumulatedText: string;
|
||||
}
|
||||
|
||||
export interface ToolLoopResult {
|
||||
response: NormalizedChatResponse;
|
||||
messages: Message[];
|
||||
accumulatedText: string;
|
||||
hasStreamedContent: boolean;
|
||||
}
|
||||
|
||||
export class ChatPipelineToolLoop {
|
||||
private readonly stages: ToolLoopStages;
|
||||
private readonly streamingStrategy: StreamingStrategy;
|
||||
private readonly updateStageMetrics: (stageName: string, startTime: number) => void;
|
||||
|
||||
constructor(dependencies: ToolLoopDependencies) {
|
||||
this.stages = dependencies.stages;
|
||||
this.streamingStrategy = dependencies.streamingStrategy;
|
||||
this.updateStageMetrics = dependencies.updateStageMetrics;
|
||||
}
|
||||
|
||||
async run(input: ToolLoopInput): Promise<ToolLoopResult> {
|
||||
const {
|
||||
messages,
|
||||
userQuery,
|
||||
providerName,
|
||||
options,
|
||||
chunkOptions,
|
||||
streamCallback,
|
||||
shouldEnableStream,
|
||||
providerStream,
|
||||
toolsEnabled,
|
||||
maxToolCallIterations
|
||||
} = input;
|
||||
|
||||
let accumulatedText = input.accumulatedText;
|
||||
|
||||
log.info(`========== STAGE 5: LLM COMPLETION ==========`);
|
||||
const llmStartTime = Date.now();
|
||||
const completion = await this.stages.llmCompletion.execute({
|
||||
messages,
|
||||
options: {
|
||||
...options,
|
||||
stream: providerStream
|
||||
}
|
||||
});
|
||||
this.updateStageMetrics('llmCompletion', llmStartTime);
|
||||
log.info(`Received LLM response from model: ${completion.response.model}, provider: ${completion.response.provider}`);
|
||||
|
||||
let hasStreamedContent = false;
|
||||
|
||||
if (shouldEnableStream && completion.response.stream && streamCallback) {
|
||||
await completion.response.stream(async (chunk: StreamChunk) => {
|
||||
const processedChunk = await this.processStreamChunk(chunk, chunkOptions ?? options);
|
||||
accumulatedText += processedChunk.text;
|
||||
streamCallback(processedChunk.text, processedChunk.done, chunk);
|
||||
hasStreamedContent = true;
|
||||
});
|
||||
}
|
||||
|
||||
let currentMessages = messages;
|
||||
let currentResponse = completion.response;
|
||||
|
||||
log.info(`========== TOOL EXECUTION DECISION ==========`);
|
||||
log.info(`Tools enabled in options: ${toolsEnabled}`);
|
||||
log.info(`Response provider: ${currentResponse.provider || 'unknown'}`);
|
||||
log.info(`Response model: ${currentResponse.model || 'unknown'}`);
|
||||
|
||||
const toolCallResolution = this.resolveToolCalls(currentResponse);
|
||||
currentResponse = toolCallResolution.response;
|
||||
let hasToolCalls = toolCallResolution.hasToolCalls;
|
||||
|
||||
if (toolsEnabled && !hasToolCalls) {
|
||||
const fallbackTool = getFallbackToolForQuery(userQuery);
|
||||
const normalizedProvider = (providerName || currentResponse.provider || '').toLowerCase();
|
||||
if (fallbackTool && normalizedProvider === 'minimax') {
|
||||
log.info(`No tool calls detected. Forcing MiniMax tool call: ${fallbackTool.name} (${fallbackTool.reason})`);
|
||||
const forcedMessages: Message[] = [
|
||||
...currentMessages,
|
||||
{
|
||||
role: 'system' as const,
|
||||
content: buildForcedToolCallInstruction(fallbackTool.name, userQuery)
|
||||
}
|
||||
];
|
||||
|
||||
const forcedToolStream = this.streamingStrategy.resolveFollowUpStreaming({
|
||||
kind: 'tool',
|
||||
hasStreamCallback: !!streamCallback,
|
||||
providerName,
|
||||
toolsEnabled: true
|
||||
});
|
||||
|
||||
const forcedCompletion = await this.stages.llmCompletion.execute({
|
||||
messages: forcedMessages,
|
||||
options: {
|
||||
...options,
|
||||
enableTools: true,
|
||||
stream: forcedToolStream,
|
||||
tool_choice: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: fallbackTool.name
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
currentResponse = forcedCompletion.response;
|
||||
if (currentResponse.tool_calls && currentResponse.tool_calls.length > 0) {
|
||||
hasToolCalls = true;
|
||||
log.info(`Forced tool call produced ${currentResponse.tool_calls.length} tool(s).`);
|
||||
} else {
|
||||
log.info(`Forced tool call did not produce tool_calls. Injecting synthetic tool call.`);
|
||||
currentResponse.tool_calls = buildSyntheticToolCalls(fallbackTool.name, userQuery);
|
||||
hasToolCalls = currentResponse.tool_calls.length > 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const executionResult = await executeToolExecutionLoop(
|
||||
{
|
||||
stages: this.stages,
|
||||
streamingStrategy: this.streamingStrategy,
|
||||
updateStageMetrics: this.updateStageMetrics
|
||||
},
|
||||
{
|
||||
response: currentResponse,
|
||||
messages: currentMessages,
|
||||
options,
|
||||
chunkOptions,
|
||||
streamCallback,
|
||||
shouldEnableStream,
|
||||
toolsEnabled,
|
||||
hasToolCalls,
|
||||
maxToolCallIterations,
|
||||
accumulatedText,
|
||||
hasStreamedContent
|
||||
}
|
||||
);
|
||||
|
||||
currentResponse = executionResult.response;
|
||||
currentMessages = executionResult.messages;
|
||||
|
||||
return {
|
||||
response: currentResponse,
|
||||
messages: currentMessages,
|
||||
accumulatedText,
|
||||
hasStreamedContent
|
||||
};
|
||||
}
|
||||
|
||||
private resolveToolCalls(response: NormalizedChatResponse): { response: NormalizedChatResponse; hasToolCalls: boolean } {
|
||||
let hasToolCalls = false;
|
||||
|
||||
log.info(`[TOOL CALL DEBUG] Starting tool call detection for provider: ${response.provider}`);
|
||||
log.info(`[TOOL CALL DEBUG] Response properties: ${Object.keys(response).join(', ')}`);
|
||||
|
||||
if ('tool_calls' in response) {
|
||||
log.info(`[TOOL CALL DEBUG] tool_calls exists as a direct property`);
|
||||
log.info(`[TOOL CALL DEBUG] tool_calls type: ${typeof response.tool_calls}`);
|
||||
|
||||
if (response.tool_calls && Array.isArray(response.tool_calls)) {
|
||||
log.info(`[TOOL CALL DEBUG] tool_calls is an array with length: ${response.tool_calls.length}`);
|
||||
} else {
|
||||
log.info(`[TOOL CALL DEBUG] tool_calls is not an array or is empty: ${JSON.stringify(response.tool_calls)}`);
|
||||
}
|
||||
} else {
|
||||
log.info(`[TOOL CALL DEBUG] tool_calls does not exist as a direct property`);
|
||||
}
|
||||
|
||||
if (response.tool_calls && response.tool_calls.length > 0) {
|
||||
hasToolCalls = true;
|
||||
log.info(`Response has tool_calls property with ${response.tool_calls.length} tools`);
|
||||
log.info(`Tool calls details: ${JSON.stringify(response.tool_calls)}`);
|
||||
} else {
|
||||
log.info(`[TOOL CALL DEBUG] Direct property check failed, trying getter approach`);
|
||||
try {
|
||||
const toolCallsDesc = Object.getOwnPropertyDescriptor(response, 'tool_calls');
|
||||
|
||||
if (toolCallsDesc) {
|
||||
log.info(`[TOOL CALL DEBUG] Found property descriptor for tool_calls: ${JSON.stringify({
|
||||
configurable: toolCallsDesc.configurable,
|
||||
enumerable: toolCallsDesc.enumerable,
|
||||
hasGetter: !!toolCallsDesc.get,
|
||||
hasSetter: !!toolCallsDesc.set
|
||||
})}`);
|
||||
} else {
|
||||
log.info(`[TOOL CALL DEBUG] No property descriptor found for tool_calls`);
|
||||
}
|
||||
|
||||
if (toolCallsDesc && typeof toolCallsDesc.get === 'function') {
|
||||
log.info(`[TOOL CALL DEBUG] Attempting to call the tool_calls getter`);
|
||||
const dynamicToolCalls = toolCallsDesc.get.call(response) as NormalizedChatResponse['tool_calls'];
|
||||
|
||||
log.info(`[TOOL CALL DEBUG] Getter returned: ${JSON.stringify(dynamicToolCalls)}`);
|
||||
|
||||
if (dynamicToolCalls && dynamicToolCalls.length > 0) {
|
||||
hasToolCalls = true;
|
||||
log.info(`Response has dynamic tool_calls with ${dynamicToolCalls.length} tools`);
|
||||
log.info(`Dynamic tool calls details: ${JSON.stringify(dynamicToolCalls)}`);
|
||||
response.tool_calls = dynamicToolCalls;
|
||||
log.info(`[TOOL CALL DEBUG] Updated response.tool_calls with dynamic values`);
|
||||
} else {
|
||||
log.info(`[TOOL CALL DEBUG] Getter returned no valid tool calls`);
|
||||
}
|
||||
} else {
|
||||
log.info(`[TOOL CALL DEBUG] No getter function found for tool_calls`);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
log.error(`Error checking dynamic tool_calls: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
log.info(`Response has tool_calls: ${hasToolCalls ? 'true' : 'false'}`);
|
||||
if (hasToolCalls && response.tool_calls) {
|
||||
log.info(`[TOOL CALL DEBUG] Final tool_calls that will be used: ${JSON.stringify(response.tool_calls)}`);
|
||||
}
|
||||
|
||||
return { response, hasToolCalls };
|
||||
}
|
||||
|
||||
private async processStreamChunk(chunk: StreamChunk, options?: ChatCompletionOptions): Promise<StreamChunk> {
|
||||
try {
|
||||
if (!chunk.text) {
|
||||
return chunk;
|
||||
}
|
||||
|
||||
const miniResponse: NormalizedChatResponse = {
|
||||
text: chunk.text,
|
||||
model: 'streaming',
|
||||
provider: 'streaming',
|
||||
tool_calls: []
|
||||
};
|
||||
|
||||
const processed = await this.stages.responseProcessing.execute({
|
||||
response: miniResponse,
|
||||
options: options ?? { enableTools: false }
|
||||
});
|
||||
|
||||
return {
|
||||
...chunk,
|
||||
text: processed.text
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
log.error(`Error processing stream chunk: ${errorMessage}`);
|
||||
return chunk;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import type { Message, ChatCompletionOptions, ChatResponse, StreamChunk } from '../ai_interface.js';
|
||||
import type { Message, ChatCompletionOptions, NormalizedChatResponse, StreamChunk } from '../ai_interface.js';
|
||||
import type { LLMServiceInterface } from '../interfaces/agent_tool_interfaces.js';
|
||||
|
||||
/**
|
||||
@ -142,7 +142,7 @@ export interface LLMCompletionInput extends PipelineInput {
|
||||
* Interface for the pipeline stage that performs response processing
|
||||
*/
|
||||
export interface ResponseProcessingInput extends PipelineInput {
|
||||
response: ChatResponse;
|
||||
response: NormalizedChatResponse;
|
||||
options: ChatCompletionOptions;
|
||||
}
|
||||
|
||||
@ -150,7 +150,7 @@ export interface ResponseProcessingInput extends PipelineInput {
|
||||
* Interface for the pipeline stage that handles tool execution
|
||||
*/
|
||||
export interface ToolExecutionInput extends PipelineInput {
|
||||
response: ChatResponse;
|
||||
response: NormalizedChatResponse;
|
||||
messages: Message[];
|
||||
options: ChatCompletionOptions;
|
||||
maxIterations?: number;
|
||||
|
||||
@ -1,14 +1,15 @@
|
||||
import { BasePipelineStage } from '../pipeline_stage.js';
|
||||
import type { LLMCompletionInput } from '../interfaces.js';
|
||||
import type { ChatCompletionOptions, ChatResponse, StreamChunk } from '../../ai_interface.js';
|
||||
import type { ChatCompletionOptions, NormalizedChatResponse, StreamChunk } from '../../ai_interface.js';
|
||||
import aiServiceManager from '../../ai_service_manager.js';
|
||||
import toolRegistry from '../../tools/tool_registry.js';
|
||||
import log from '../../../log.js';
|
||||
import { normalizeChatResponse } from '../../response_normalizer.js';
|
||||
|
||||
/**
|
||||
* Pipeline stage for LLM completion with enhanced streaming support
|
||||
*/
|
||||
export class LLMCompletionStage extends BasePipelineStage<LLMCompletionInput, { response: ChatResponse }> {
|
||||
export class LLMCompletionStage extends BasePipelineStage<LLMCompletionInput, { response: NormalizedChatResponse }> {
|
||||
constructor() {
|
||||
super('LLMCompletion');
|
||||
}
|
||||
@ -19,7 +20,7 @@ export class LLMCompletionStage extends BasePipelineStage<LLMCompletionInput, {
|
||||
* This enhanced version supports better streaming by forwarding raw provider data
|
||||
* and ensuring consistent handling of stream options.
|
||||
*/
|
||||
protected async process(input: LLMCompletionInput): Promise<{ response: ChatResponse }> {
|
||||
protected async process(input: LLMCompletionInput): Promise<{ response: NormalizedChatResponse }> {
|
||||
const { messages, options } = input;
|
||||
|
||||
// Add detailed logging about the input messages, particularly useful for tool follow-ups
|
||||
@ -98,6 +99,16 @@ export class LLMCompletionStage extends BasePipelineStage<LLMCompletionInput, {
|
||||
if (updatedOptions.providerMetadata?.provider) {
|
||||
selectedProvider = updatedOptions.providerMetadata.provider;
|
||||
log.info(`Using provider ${selectedProvider} from metadata for model ${updatedOptions.model}`);
|
||||
} else {
|
||||
try {
|
||||
const configuredProvider = aiServiceManager.getSelectedProvider();
|
||||
if (configuredProvider) {
|
||||
selectedProvider = configuredProvider;
|
||||
log.info(`Using configured provider ${selectedProvider} for model ${updatedOptions.model}`);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
log.error(`Failed to resolve configured provider: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
log.info(`Generating LLM completion, provider: ${selectedProvider || 'auto'}, model: ${updatedOptions?.model || 'default'}`);
|
||||
@ -109,13 +120,14 @@ export class LLMCompletionStage extends BasePipelineStage<LLMCompletionInput, {
|
||||
|
||||
// Generate completion and wrap with enhanced stream handling
|
||||
const response = await service.generateChatCompletion(messages, updatedOptions);
|
||||
const normalizedResponse = service.toNormalizedResponse(response);
|
||||
|
||||
// If streaming is enabled, enhance the stream method
|
||||
if (response.stream && typeof response.stream === 'function' && updatedOptions.stream) {
|
||||
const originalStream = response.stream;
|
||||
if (normalizedResponse.stream && typeof normalizedResponse.stream === 'function' && updatedOptions.stream) {
|
||||
const originalStream = normalizedResponse.stream;
|
||||
|
||||
// Replace the stream method with an enhanced version that captures and forwards raw data
|
||||
response.stream = async (callback) => {
|
||||
normalizedResponse.stream = async (callback) => {
|
||||
return originalStream(async (chunk) => {
|
||||
// Forward the chunk with any additional provider-specific data
|
||||
// Create an enhanced chunk with provider info
|
||||
@ -124,7 +136,7 @@ export class LLMCompletionStage extends BasePipelineStage<LLMCompletionInput, {
|
||||
// If the provider didn't include raw data, add minimal info
|
||||
raw: chunk.raw || {
|
||||
provider: selectedProvider,
|
||||
model: response.model
|
||||
model: normalizedResponse.model
|
||||
}
|
||||
};
|
||||
return callback(enhancedChunk);
|
||||
@ -134,9 +146,9 @@ export class LLMCompletionStage extends BasePipelineStage<LLMCompletionInput, {
|
||||
|
||||
// Add enhanced logging for debugging tool execution follow-ups
|
||||
if (toolMessages.length > 0) {
|
||||
if (response.tool_calls && response.tool_calls.length > 0) {
|
||||
log.info(`Response contains ${response.tool_calls.length} tool calls`);
|
||||
response.tool_calls.forEach((toolCall: any, idx: number) => {
|
||||
if (normalizedResponse.tool_calls.length > 0) {
|
||||
log.info(`Response contains ${normalizedResponse.tool_calls.length} tool calls`);
|
||||
normalizedResponse.tool_calls.forEach((toolCall, idx: number) => {
|
||||
log.info(`Tool call ${idx + 1}: ${toolCall.function?.name || 'unnamed'}`);
|
||||
const args = typeof toolCall.function?.arguments === 'string'
|
||||
? toolCall.function?.arguments
|
||||
@ -147,31 +159,32 @@ export class LLMCompletionStage extends BasePipelineStage<LLMCompletionInput, {
|
||||
log.info(`Response contains no tool calls - plain text response`);
|
||||
}
|
||||
|
||||
if (toolMessages.length > 0 && !response.tool_calls) {
|
||||
if (toolMessages.length > 0 && normalizedResponse.tool_calls.length === 0) {
|
||||
log.info(`This appears to be a final response after tool execution (no new tool calls)`);
|
||||
} else if (toolMessages.length > 0 && response.tool_calls && response.tool_calls.length > 0) {
|
||||
} else if (toolMessages.length > 0 && normalizedResponse.tool_calls.length > 0) {
|
||||
log.info(`This appears to be a continued tool execution flow (tools followed by more tools)`);
|
||||
}
|
||||
}
|
||||
|
||||
return { response };
|
||||
return { response: normalizedResponse };
|
||||
}
|
||||
|
||||
// Use auto-selection if no specific provider
|
||||
log.info(`[LLMCompletionStage] Using auto-selected service`);
|
||||
const response = await aiServiceManager.generateChatCompletion(messages, updatedOptions);
|
||||
const normalizedResponse = normalizeChatResponse(response);
|
||||
|
||||
// Add similar stream enhancement for auto-selected provider
|
||||
if (response.stream && typeof response.stream === 'function' && updatedOptions.stream) {
|
||||
const originalStream = response.stream;
|
||||
response.stream = async (callback) => {
|
||||
if (normalizedResponse.stream && typeof normalizedResponse.stream === 'function' && updatedOptions.stream) {
|
||||
const originalStream = normalizedResponse.stream;
|
||||
normalizedResponse.stream = async (callback) => {
|
||||
return originalStream(async (chunk) => {
|
||||
// Create an enhanced chunk with provider info
|
||||
const enhancedChunk: StreamChunk = {
|
||||
...chunk,
|
||||
raw: chunk.raw || {
|
||||
provider: response.provider,
|
||||
model: response.model
|
||||
provider: normalizedResponse.provider,
|
||||
model: normalizedResponse.model
|
||||
}
|
||||
};
|
||||
return callback(enhancedChunk);
|
||||
@ -181,9 +194,9 @@ export class LLMCompletionStage extends BasePipelineStage<LLMCompletionInput, {
|
||||
|
||||
// Add enhanced logging for debugging tool execution follow-ups
|
||||
if (toolMessages.length > 0) {
|
||||
if (response.tool_calls && response.tool_calls.length > 0) {
|
||||
log.info(`Response contains ${response.tool_calls.length} tool calls`);
|
||||
response.tool_calls.forEach((toolCall: any, idx: number) => {
|
||||
if (normalizedResponse.tool_calls.length > 0) {
|
||||
log.info(`Response contains ${normalizedResponse.tool_calls.length} tool calls`);
|
||||
normalizedResponse.tool_calls.forEach((toolCall, idx: number) => {
|
||||
log.info(`Tool call ${idx + 1}: ${toolCall.function?.name || 'unnamed'}`);
|
||||
const args = typeof toolCall.function?.arguments === 'string'
|
||||
? toolCall.function?.arguments
|
||||
@ -194,13 +207,13 @@ export class LLMCompletionStage extends BasePipelineStage<LLMCompletionInput, {
|
||||
log.info(`Response contains no tool calls - plain text response`);
|
||||
}
|
||||
|
||||
if (toolMessages.length > 0 && !response.tool_calls) {
|
||||
if (toolMessages.length > 0 && normalizedResponse.tool_calls.length === 0) {
|
||||
log.info(`This appears to be a final response after tool execution (no new tool calls)`);
|
||||
} else if (toolMessages.length > 0 && response.tool_calls && response.tool_calls.length > 0) {
|
||||
} else if (toolMessages.length > 0 && normalizedResponse.tool_calls.length > 0) {
|
||||
log.info(`This appears to be a continued tool execution flow (tools followed by more tools)`);
|
||||
}
|
||||
}
|
||||
|
||||
return { response };
|
||||
return { response: normalizedResponse };
|
||||
}
|
||||
}
|
||||
|
||||
@ -183,7 +183,7 @@ export class ModelSelectionStage extends BasePipelineStage<ModelSelectionInput,
|
||||
// Set the provider metadata in the options
|
||||
if (selectedProvider) {
|
||||
// Ensure the provider is one of the valid types
|
||||
const validProvider = selectedProvider as 'openai' | 'anthropic' | 'ollama' | 'local';
|
||||
const validProvider = selectedProvider as 'openai' | 'anthropic' | 'ollama' | 'local' | 'minimax';
|
||||
|
||||
options.providerMetadata = {
|
||||
provider: validProvider,
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import { BasePipelineStage } from '../pipeline_stage.js';
|
||||
import type { ResponseProcessingInput } from '../interfaces.js';
|
||||
import type { ChatResponse } from '../../ai_interface.js';
|
||||
import log from '../../../log.js';
|
||||
|
||||
/**
|
||||
|
||||
@ -0,0 +1,440 @@
|
||||
import type { ChatCompletionOptions, Message, NormalizedChatResponse } from '../../ai_interface.js';
|
||||
import type { StreamCallback } from '../interfaces.js';
|
||||
import log from '../../../log.js';
|
||||
import toolRegistry from '../../tools/tool_registry.js';
|
||||
import { parseToolArguments } from '../../tools/tool_argument_parser.js';
|
||||
import chatStorageService from '../../chat_storage_service.js';
|
||||
import {
|
||||
generateToolGuidance,
|
||||
isEmptyToolResult,
|
||||
validateToolBeforeExecution,
|
||||
type ToolInterface
|
||||
} from './tool_calling_helpers.js';
|
||||
|
||||
interface ToolValidationResult {
|
||||
toolCall: {
|
||||
id?: string;
|
||||
function: {
|
||||
name: string;
|
||||
arguments: string | Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
valid: boolean;
|
||||
tool: ToolInterface | null;
|
||||
error: string | null;
|
||||
guidance?: string;
|
||||
}
|
||||
|
||||
export interface ToolCallingExecutionInput {
|
||||
response: NormalizedChatResponse;
|
||||
messages: Message[];
|
||||
options: ChatCompletionOptions;
|
||||
streamCallback?: StreamCallback;
|
||||
}
|
||||
|
||||
export interface ToolCallingExecutionOutput {
|
||||
response: NormalizedChatResponse;
|
||||
needsFollowUp: boolean;
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
export const executeToolCalling: (input: ToolCallingExecutionInput) => Promise<ToolCallingExecutionOutput> = async (input) => {
|
||||
const { response, messages, options, streamCallback } = input;
|
||||
|
||||
log.info(`========== TOOL CALLING STAGE ENTRY ==========`);
|
||||
log.info(`Response provider: ${response.provider}, model: ${response.model || 'unknown'}`);
|
||||
|
||||
log.info(`LLM requested ${response.tool_calls?.length || 0} tool calls from provider: ${response.provider}`);
|
||||
|
||||
if (!response.tool_calls || response.tool_calls.length === 0) {
|
||||
log.info(`No tool calls detected in response from provider: ${response.provider}`);
|
||||
log.info(`===== EXITING TOOL CALLING STAGE: No tool_calls =====`);
|
||||
return { response, needsFollowUp: false, messages };
|
||||
}
|
||||
|
||||
if (response.text) {
|
||||
log.info(`Response text: "${response.text.substring(0, 200)}${response.text.length > 200 ? '...' : ''}"`);
|
||||
}
|
||||
|
||||
const registryTools = toolRegistry.getAllTools();
|
||||
|
||||
const availableTools: ToolInterface[] = registryTools.map(tool => {
|
||||
const toolInterface: ToolInterface = {
|
||||
execute: (args: Record<string, unknown>) => tool.execute(args),
|
||||
...tool.definition
|
||||
};
|
||||
return toolInterface;
|
||||
});
|
||||
log.info(`Available tools in registry: ${availableTools.length}`);
|
||||
|
||||
if (availableTools.length > 0) {
|
||||
const availableToolNames = availableTools.map(t => {
|
||||
if (t && typeof t === 'object' && 'definition' in t &&
|
||||
t.definition && typeof t.definition === 'object' &&
|
||||
'function' in t.definition && t.definition.function &&
|
||||
typeof t.definition.function === 'object' &&
|
||||
'name' in t.definition.function &&
|
||||
typeof t.definition.function.name === 'string') {
|
||||
return t.definition.function.name;
|
||||
}
|
||||
return 'unknown';
|
||||
}).join(', ');
|
||||
log.info(`Available tools: ${availableToolNames}`);
|
||||
}
|
||||
|
||||
if (availableTools.length === 0) {
|
||||
log.error(`No tools available in registry, cannot execute tool calls`);
|
||||
try {
|
||||
log.info('Attempting to initialize tools as recovery step');
|
||||
const toolCount = toolRegistry.getAllTools().length;
|
||||
log.info(`After recovery initialization: ${toolCount} tools available`);
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
log.error(`Failed to initialize tools in recovery step: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
const updatedMessages = [...messages];
|
||||
|
||||
updatedMessages.push({
|
||||
role: 'assistant',
|
||||
content: response.text || "",
|
||||
tool_calls: response.tool_calls
|
||||
});
|
||||
|
||||
log.info(`========== STARTING TOOL EXECUTION ==========`);
|
||||
log.info(`Executing ${response.tool_calls?.length || 0} tool calls in parallel`);
|
||||
|
||||
const executionStartTime = Date.now();
|
||||
|
||||
log.info(`Validating ${response.tool_calls?.length || 0} tools before execution`);
|
||||
const validationResults: ToolValidationResult[] = await Promise.all((response.tool_calls || []).map(async (toolCall) => {
|
||||
try {
|
||||
const tool = toolRegistry.getTool(toolCall.function.name);
|
||||
|
||||
if (!tool) {
|
||||
log.error(`Tool not found in registry: ${toolCall.function.name}`);
|
||||
const guidance = generateToolGuidance(toolCall.function.name, `Tool not found: ${toolCall.function.name}`);
|
||||
return {
|
||||
toolCall,
|
||||
valid: false,
|
||||
tool: null,
|
||||
error: `Tool not found: ${toolCall.function.name}`,
|
||||
guidance
|
||||
};
|
||||
}
|
||||
|
||||
const isToolValid = await validateToolBeforeExecution(tool as unknown as ToolInterface, toolCall.function.name);
|
||||
if (!isToolValid) {
|
||||
throw new Error(`Tool '${toolCall.function.name}' failed validation before execution`);
|
||||
}
|
||||
|
||||
return {
|
||||
toolCall,
|
||||
valid: true,
|
||||
tool: tool as unknown as ToolInterface,
|
||||
error: null
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
toolCall,
|
||||
valid: false,
|
||||
tool: null,
|
||||
error: errorMessage
|
||||
};
|
||||
}
|
||||
}));
|
||||
|
||||
const toolResults = await Promise.all(validationResults.map(async (validation, index) => {
|
||||
const { toolCall, valid, tool, error } = validation;
|
||||
|
||||
try {
|
||||
log.info(`========== TOOL CALL ${index + 1} OF ${response.tool_calls?.length || 0} ==========`);
|
||||
log.info(`Tool call ${index + 1} received - Name: ${toolCall.function.name}, ID: ${toolCall.id || 'unknown'}`);
|
||||
|
||||
const argsStr = typeof toolCall.function.arguments === 'string'
|
||||
? toolCall.function.arguments
|
||||
: JSON.stringify(toolCall.function.arguments);
|
||||
log.info(`Tool parameters: ${argsStr}`);
|
||||
|
||||
if (!valid || !tool) {
|
||||
const toolGuidance = validation.guidance ||
|
||||
generateToolGuidance(toolCall.function.name,
|
||||
error || `Unknown validation error for tool '${toolCall.function.name}'`);
|
||||
|
||||
throw new Error(`${error || `Unknown validation error for tool '${toolCall.function.name}'`}\n${toolGuidance}`);
|
||||
}
|
||||
|
||||
log.info(`Tool validated successfully: ${toolCall.function.name}`);
|
||||
|
||||
const metadata = toolRegistry.getToolMetadata(toolCall.function.name);
|
||||
const parser = metadata?.parseArguments || parseToolArguments;
|
||||
const rawArguments = (typeof toolCall.function.arguments === 'string'
|
||||
|| (toolCall.function.arguments && typeof toolCall.function.arguments === 'object'))
|
||||
? toolCall.function.arguments
|
||||
: '';
|
||||
|
||||
const parsedArguments = parser(rawArguments);
|
||||
if (parsedArguments.warnings.length > 0) {
|
||||
parsedArguments.warnings.forEach(warning => {
|
||||
log.info(`Tool argument parse warning (${toolCall.function.name}): ${warning}`);
|
||||
});
|
||||
}
|
||||
|
||||
const args = parsedArguments.args;
|
||||
log.info(`Parsed tool arguments keys: ${Object.keys(args).join(', ')}`);
|
||||
|
||||
log.info(`================ EXECUTING TOOL: ${toolCall.function.name} ================`);
|
||||
log.info(`Tool parameters: ${Object.keys(args).join(', ')}`);
|
||||
log.info(`Parameters values: ${Object.entries(args).map(([k, v]) => `${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`).join(', ')}`);
|
||||
|
||||
if (streamCallback) {
|
||||
const toolExecutionData = {
|
||||
action: 'start',
|
||||
tool: {
|
||||
name: toolCall.function.name,
|
||||
arguments: args
|
||||
},
|
||||
type: 'start' as const
|
||||
};
|
||||
|
||||
const callbackResult = streamCallback('', false, {
|
||||
text: '',
|
||||
done: false,
|
||||
toolExecution: toolExecutionData
|
||||
});
|
||||
if (callbackResult instanceof Promise) {
|
||||
callbackResult.catch((e: Error) => log.error(`Error sending tool execution start event: ${e.message}`));
|
||||
}
|
||||
}
|
||||
|
||||
const executionStart = Date.now();
|
||||
let result;
|
||||
try {
|
||||
log.info(`Starting tool execution for ${toolCall.function.name}...`);
|
||||
result = await tool.execute(args);
|
||||
const executionTime = Date.now() - executionStart;
|
||||
log.info(`================ TOOL EXECUTION COMPLETED in ${executionTime}ms ================`);
|
||||
|
||||
if (options?.sessionId) {
|
||||
try {
|
||||
await chatStorageService.recordToolExecution(
|
||||
options.sessionId,
|
||||
toolCall.function.name,
|
||||
toolCall.id || `tool-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
args,
|
||||
result,
|
||||
undefined
|
||||
);
|
||||
} catch (storageError) {
|
||||
log.error(`Failed to record tool execution in chat storage: ${storageError}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (streamCallback) {
|
||||
const toolExecutionData = {
|
||||
action: 'complete',
|
||||
tool: {
|
||||
name: toolCall.function.name,
|
||||
arguments: {} as Record<string, unknown>
|
||||
},
|
||||
result: typeof result === 'string' ? result : result as Record<string, unknown>,
|
||||
type: 'complete' as const
|
||||
};
|
||||
|
||||
const callbackResult = streamCallback('', false, {
|
||||
text: '',
|
||||
done: false,
|
||||
toolExecution: toolExecutionData
|
||||
});
|
||||
if (callbackResult instanceof Promise) {
|
||||
callbackResult.catch((e: Error) => log.error(`Error sending tool execution complete event: ${e.message}`));
|
||||
}
|
||||
}
|
||||
} catch (execError: unknown) {
|
||||
const executionTime = Date.now() - executionStart;
|
||||
const errorMessage = execError instanceof Error ? execError.message : String(execError);
|
||||
log.error(`================ TOOL EXECUTION FAILED in ${executionTime}ms: ${errorMessage} ================`);
|
||||
|
||||
const toolGuidance = generateToolGuidance(toolCall.function.name, errorMessage);
|
||||
const enhancedErrorMessage = `${errorMessage}\n${toolGuidance}`;
|
||||
|
||||
if (options?.sessionId) {
|
||||
try {
|
||||
await chatStorageService.recordToolExecution(
|
||||
options.sessionId,
|
||||
toolCall.function.name,
|
||||
toolCall.id || `tool-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
args,
|
||||
"",
|
||||
enhancedErrorMessage
|
||||
);
|
||||
} catch (storageError) {
|
||||
log.error(`Failed to record tool execution error in chat storage: ${storageError}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (streamCallback) {
|
||||
const toolExecutionData = {
|
||||
action: 'error',
|
||||
tool: {
|
||||
name: toolCall.function.name,
|
||||
arguments: {} as Record<string, unknown>
|
||||
},
|
||||
error: enhancedErrorMessage,
|
||||
type: 'error' as const
|
||||
};
|
||||
|
||||
const callbackResult = streamCallback('', false, {
|
||||
text: '',
|
||||
done: false,
|
||||
toolExecution: toolExecutionData
|
||||
});
|
||||
if (callbackResult instanceof Promise) {
|
||||
callbackResult.catch((e: Error) => log.error(`Error sending tool execution error event: ${e.message}`));
|
||||
}
|
||||
}
|
||||
|
||||
if (execError instanceof Error) {
|
||||
execError.message = enhancedErrorMessage;
|
||||
}
|
||||
throw execError;
|
||||
}
|
||||
|
||||
const resultSummary = typeof result === 'string'
|
||||
? `${result.substring(0, 100)}...`
|
||||
: `Object with keys: ${Object.keys(result).join(', ')}`;
|
||||
const executionTime = Date.now() - executionStart;
|
||||
log.info(`Tool execution completed in ${executionTime}ms - Result: ${resultSummary}`);
|
||||
|
||||
return {
|
||||
toolCallId: toolCall.id,
|
||||
name: toolCall.function.name,
|
||||
result
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
log.error(`Error executing tool ${toolCall.function.name}: ${errorMessage}`);
|
||||
|
||||
const isExecutionError = typeof error === 'object' && error !== null &&
|
||||
'name' in error && (error as { name: unknown }).name === "ExecutionError";
|
||||
|
||||
if (streamCallback && !isExecutionError) {
|
||||
const toolExecutionData = {
|
||||
action: 'error',
|
||||
tool: {
|
||||
name: toolCall.function.name,
|
||||
arguments: {} as Record<string, unknown>
|
||||
},
|
||||
error: errorMessage,
|
||||
type: 'error' as const
|
||||
};
|
||||
|
||||
const callbackResult = streamCallback('', false, {
|
||||
text: '',
|
||||
done: false,
|
||||
toolExecution: toolExecutionData
|
||||
});
|
||||
if (callbackResult instanceof Promise) {
|
||||
callbackResult.catch((e: Error) => log.error(`Error sending tool execution error event: ${e.message}`));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
toolCallId: toolCall.id,
|
||||
name: toolCall.function.name,
|
||||
result: `Error: ${errorMessage}`
|
||||
};
|
||||
}
|
||||
}));
|
||||
|
||||
const totalExecutionTime = Date.now() - executionStartTime;
|
||||
log.info(`========== TOOL EXECUTION COMPLETE ==========`);
|
||||
log.info(`Completed execution of ${toolResults.length} tools in ${totalExecutionTime}ms`);
|
||||
|
||||
const toolResultMessages: Message[] = [];
|
||||
let hasEmptyResults = false;
|
||||
|
||||
for (const result of toolResults) {
|
||||
const { toolCallId, name, result: toolResult } = result;
|
||||
|
||||
const resultContent = typeof toolResult === 'string'
|
||||
? toolResult
|
||||
: JSON.stringify(toolResult, null, 2);
|
||||
|
||||
const isEmptyResult = isEmptyToolResult(toolResult, name);
|
||||
if (isEmptyResult && !resultContent.startsWith('Error:')) {
|
||||
hasEmptyResults = true;
|
||||
log.info(`Empty result detected for tool ${name}. Will add suggestion to try different parameters.`);
|
||||
}
|
||||
|
||||
let enhancedContent = resultContent;
|
||||
if (isEmptyResult && !resultContent.startsWith('Error:')) {
|
||||
enhancedContent = `${resultContent}\n\nNOTE: This tool returned no useful results with the provided parameters. Consider trying again with different parameters such as broader search terms, different filters, or alternative approaches.`;
|
||||
}
|
||||
|
||||
const toolMessage: Message = {
|
||||
role: 'tool',
|
||||
content: enhancedContent,
|
||||
name: name,
|
||||
tool_call_id: toolCallId
|
||||
};
|
||||
|
||||
log.info(`-------- Tool Result for ${name} (ID: ${toolCallId}) --------`);
|
||||
log.info(`Result type: ${typeof toolResult}`);
|
||||
log.info(`Result preview: ${resultContent.substring(0, 150)}${resultContent.length > 150 ? '...' : ''}`);
|
||||
log.info(`Tool result status: ${resultContent.startsWith('Error:') ? 'ERROR' : isEmptyResult ? 'EMPTY' : 'SUCCESS'}`);
|
||||
|
||||
updatedMessages.push(toolMessage);
|
||||
toolResultMessages.push(toolMessage);
|
||||
}
|
||||
|
||||
log.info(`========== FOLLOW-UP DECISION ==========`);
|
||||
const hasToolResults = toolResultMessages.length > 0;
|
||||
const hasErrors = toolResultMessages.some(msg => msg.content.startsWith('Error:'));
|
||||
const needsFollowUp = hasToolResults;
|
||||
|
||||
log.info(`Follow-up needed: ${needsFollowUp}`);
|
||||
log.info(`Reasoning: ${hasToolResults ? 'Has tool results to process' : 'No tool results'} ${hasErrors ? ', contains errors' : ''} ${hasEmptyResults ? ', contains empty results' : ''}`);
|
||||
|
||||
if (hasEmptyResults && needsFollowUp) {
|
||||
log.info('Adding system message requiring the LLM to run additional tools with different parameters');
|
||||
|
||||
const emptyToolNames = toolResultMessages
|
||||
.filter(msg => isEmptyToolResult(msg.content, msg.name || ''))
|
||||
.map(msg => msg.name);
|
||||
|
||||
let directiveMessage = `YOU MUST NOT GIVE UP AFTER A SINGLE EMPTY SEARCH RESULT. `;
|
||||
|
||||
if (emptyToolNames.includes('search_notes') || emptyToolNames.includes('keyword_search_notes')) {
|
||||
directiveMessage += `IMMEDIATELY RUN ANOTHER SEARCH TOOL with broader search terms, alternative keywords, or related concepts. `;
|
||||
directiveMessage += `Try synonyms, more general terms, or related topics. `;
|
||||
}
|
||||
|
||||
if (emptyToolNames.includes('keyword_search_notes')) {
|
||||
directiveMessage += `IMMEDIATELY TRY SEARCH_NOTES INSTEAD as it might find matches where keyword search failed. `;
|
||||
}
|
||||
|
||||
directiveMessage += `DO NOT ask the user what to do next or if they want general information. CONTINUE SEARCHING with different parameters.`;
|
||||
|
||||
updatedMessages.push({
|
||||
role: 'system',
|
||||
content: directiveMessage
|
||||
});
|
||||
}
|
||||
|
||||
log.info(`Total messages to return to pipeline: ${updatedMessages.length}`);
|
||||
log.info(`Last 3 messages in conversation:`);
|
||||
const lastMessages = updatedMessages.slice(-3);
|
||||
lastMessages.forEach((msg, idx) => {
|
||||
const position = updatedMessages.length - lastMessages.length + idx;
|
||||
log.info(`Message ${position} (${msg.role}): ${msg.content?.substring(0, 100)}${msg.content?.length > 100 ? '...' : ''}`);
|
||||
});
|
||||
|
||||
return {
|
||||
response,
|
||||
messages: updatedMessages,
|
||||
needsFollowUp
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,91 @@
|
||||
import log from '../../../log.js';
|
||||
import toolRegistry from '../../tools/tool_registry.js';
|
||||
|
||||
export interface ToolInterface {
|
||||
execute: (args: Record<string, unknown>) => Promise<unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export const validateToolBeforeExecution: (tool: ToolInterface, toolName: string) => Promise<boolean> = async (tool, toolName) => {
|
||||
try {
|
||||
if (!tool) {
|
||||
log.error(`Tool '${toolName}' not found or failed validation`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!tool.execute || typeof tool.execute !== 'function') {
|
||||
log.error(`Tool '${toolName}' is missing execute method`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
log.error(`Error validating tool '${toolName}': ${error}`);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const generateToolGuidance: (toolName: string, errorMessage: string) => string = (toolName, errorMessage) => {
|
||||
const tools = toolRegistry.getAllTools();
|
||||
const availableTools = tools.map(tool => tool.definition?.function?.name).filter(Boolean) as string[];
|
||||
|
||||
let guidance = `Tool execution failed: ${errorMessage}\n`;
|
||||
guidance += `Available tools are: ${availableTools.join(', ')}.\n`;
|
||||
guidance += `Please choose a valid tool and ensure parameters match the required schema.`;
|
||||
|
||||
if (!availableTools.includes(toolName)) {
|
||||
guidance += `\nNote: "${toolName}" is not a valid tool name.`;
|
||||
}
|
||||
|
||||
return guidance;
|
||||
};
|
||||
|
||||
export const isEmptyToolResult: (result: unknown, toolName: string) => boolean = (result, toolName) => {
|
||||
if (result === null || result === undefined) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof result === 'string') {
|
||||
const trimmed = result.trim();
|
||||
if (trimmed.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const emptyIndicators = [
|
||||
'no results',
|
||||
'not found',
|
||||
'empty',
|
||||
'no notes found',
|
||||
'0 results'
|
||||
];
|
||||
|
||||
return emptyIndicators.some(indicator => trimmed.toLowerCase().includes(indicator));
|
||||
}
|
||||
|
||||
if (typeof result === 'object') {
|
||||
if (Array.isArray(result)) {
|
||||
return result.length === 0;
|
||||
}
|
||||
|
||||
if ('results' in result && Array.isArray((result as { results: unknown[] }).results)) {
|
||||
return (result as { results: unknown[] }).results.length === 0;
|
||||
}
|
||||
|
||||
if ('count' in result && typeof (result as { count: unknown }).count === 'number') {
|
||||
return (result as { count: number }).count === 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (toolName === 'search_notes' || toolName === 'keyword_search_notes') {
|
||||
if (typeof result === 'object' && result !== null) {
|
||||
if ('message' in result && typeof (result as { message: unknown }).message === 'string') {
|
||||
const message = (result as { message: string }).message.toLowerCase();
|
||||
if (message.includes('no notes found')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
@ -1,681 +1,22 @@
|
||||
import type { ChatResponse, Message } from '../../ai_interface.js';
|
||||
import log from '../../../log.js';
|
||||
import type { StreamCallback, ToolExecutionInput } from '../interfaces.js';
|
||||
import type { NormalizedChatResponse, Message } from '../../ai_interface.js';
|
||||
import type { ToolExecutionInput } from '../interfaces.js';
|
||||
import { BasePipelineStage } from '../pipeline_stage.js';
|
||||
import toolRegistry from '../../tools/tool_registry.js';
|
||||
import chatStorageService from '../../chat_storage_service.js';
|
||||
import aiServiceManager from '../../ai_service_manager.js';
|
||||
|
||||
// Type definitions for tools and validation results
|
||||
interface ToolInterface {
|
||||
execute: (args: Record<string, unknown>) => Promise<unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ToolValidationResult {
|
||||
toolCall: {
|
||||
id?: string;
|
||||
function: {
|
||||
name: string;
|
||||
arguments: string | Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
valid: boolean;
|
||||
tool: ToolInterface | null;
|
||||
error: string | null;
|
||||
guidance?: string; // Guidance to help the LLM select better tools/parameters
|
||||
}
|
||||
import { executeToolCalling } from './tool_calling_executor.js';
|
||||
|
||||
/**
|
||||
* Pipeline stage for handling LLM tool calling
|
||||
* This stage is responsible for:
|
||||
* 1. Detecting tool calls in LLM responses
|
||||
* 2. Executing the appropriate tools
|
||||
* 3. Adding tool results back to the conversation
|
||||
* 4. Determining if we need to make another call to the LLM
|
||||
* Pipeline stage for handling LLM tool calling.
|
||||
*/
|
||||
export class ToolCallingStage extends BasePipelineStage<ToolExecutionInput, { response: ChatResponse, needsFollowUp: boolean, messages: Message[] }> {
|
||||
export class ToolCallingStage extends BasePipelineStage<ToolExecutionInput, { response: NormalizedChatResponse, needsFollowUp: boolean, messages: Message[] }> {
|
||||
constructor() {
|
||||
super('ToolCalling');
|
||||
// Vector search tool has been removed - no preloading needed
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the LLM response and execute any tool calls
|
||||
*/
|
||||
protected async process(input: ToolExecutionInput): Promise<{ response: ChatResponse, needsFollowUp: boolean, messages: Message[] }> {
|
||||
const { response, messages } = input;
|
||||
const streamCallback = input.streamCallback as StreamCallback;
|
||||
|
||||
log.info(`========== TOOL CALLING STAGE ENTRY ==========`);
|
||||
log.info(`Response provider: ${response.provider}, model: ${response.model || 'unknown'}`);
|
||||
|
||||
log.info(`LLM requested ${response.tool_calls?.length || 0} tool calls from provider: ${response.provider}`);
|
||||
|
||||
// Check if the response has tool calls
|
||||
if (!response.tool_calls || response.tool_calls.length === 0) {
|
||||
// No tool calls, return original response and messages
|
||||
log.info(`No tool calls detected in response from provider: ${response.provider}`);
|
||||
log.info(`===== EXITING TOOL CALLING STAGE: No tool_calls =====`);
|
||||
return { response, needsFollowUp: false, messages };
|
||||
}
|
||||
|
||||
// Log response details for debugging
|
||||
if (response.text) {
|
||||
log.info(`Response text: "${response.text.substring(0, 200)}${response.text.length > 200 ? '...' : ''}"`);
|
||||
}
|
||||
|
||||
// Check if the registry has any tools
|
||||
const registryTools = toolRegistry.getAllTools();
|
||||
|
||||
// Convert ToolHandler[] to ToolInterface[] with proper type safety
|
||||
const availableTools: ToolInterface[] = registryTools.map(tool => {
|
||||
// Create a proper ToolInterface from the ToolHandler
|
||||
const toolInterface: ToolInterface = {
|
||||
// Pass through the execute method
|
||||
execute: (args: Record<string, unknown>) => tool.execute(args),
|
||||
// Include other properties from the tool definition
|
||||
...tool.definition
|
||||
};
|
||||
return toolInterface;
|
||||
protected async process(input: ToolExecutionInput): Promise<{ response: NormalizedChatResponse, needsFollowUp: boolean, messages: Message[] }> {
|
||||
return executeToolCalling({
|
||||
response: input.response,
|
||||
messages: input.messages,
|
||||
options: input.options,
|
||||
streamCallback: input.streamCallback
|
||||
});
|
||||
log.info(`Available tools in registry: ${availableTools.length}`);
|
||||
|
||||
// Log available tools for debugging
|
||||
if (availableTools.length > 0) {
|
||||
const availableToolNames = availableTools.map(t => {
|
||||
// Safely access the name property using type narrowing
|
||||
if (t && typeof t === 'object' && 'definition' in t &&
|
||||
t.definition && typeof t.definition === 'object' &&
|
||||
'function' in t.definition && t.definition.function &&
|
||||
typeof t.definition.function === 'object' &&
|
||||
'name' in t.definition.function &&
|
||||
typeof t.definition.function.name === 'string') {
|
||||
return t.definition.function.name;
|
||||
}
|
||||
return 'unknown';
|
||||
}).join(', ');
|
||||
log.info(`Available tools: ${availableToolNames}`);
|
||||
}
|
||||
|
||||
if (availableTools.length === 0) {
|
||||
log.error(`No tools available in registry, cannot execute tool calls`);
|
||||
// Try to initialize tools as a recovery step
|
||||
try {
|
||||
log.info('Attempting to initialize tools as recovery step');
|
||||
// Tools are already initialized in the AIServiceManager constructor
|
||||
// No need to initialize them again
|
||||
const toolCount = toolRegistry.getAllTools().length;
|
||||
log.info(`After recovery initialization: ${toolCount} tools available`);
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
log.error(`Failed to initialize tools in recovery step: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Create a copy of messages to add the assistant message with tool calls
|
||||
const updatedMessages = [...messages];
|
||||
|
||||
// Add the assistant message with the tool calls
|
||||
updatedMessages.push({
|
||||
role: 'assistant',
|
||||
content: response.text || "",
|
||||
tool_calls: response.tool_calls
|
||||
});
|
||||
|
||||
// Execute each tool call and add results to messages
|
||||
log.info(`========== STARTING TOOL EXECUTION ==========`);
|
||||
log.info(`Executing ${response.tool_calls?.length || 0} tool calls in parallel`);
|
||||
|
||||
const executionStartTime = Date.now();
|
||||
|
||||
// First validate all tools before execution
|
||||
log.info(`Validating ${response.tool_calls?.length || 0} tools before execution`);
|
||||
const validationResults: ToolValidationResult[] = await Promise.all((response.tool_calls || []).map(async (toolCall) => {
|
||||
try {
|
||||
// Get the tool from registry
|
||||
const tool = toolRegistry.getTool(toolCall.function.name);
|
||||
|
||||
if (!tool) {
|
||||
log.error(`Tool not found in registry: ${toolCall.function.name}`);
|
||||
// Generate guidance for the LLM when a tool is not found
|
||||
const guidance = this.generateToolGuidance(toolCall.function.name, `Tool not found: ${toolCall.function.name}`);
|
||||
return {
|
||||
toolCall,
|
||||
valid: false,
|
||||
tool: null,
|
||||
error: `Tool not found: ${toolCall.function.name}`,
|
||||
guidance // Add guidance for the LLM
|
||||
};
|
||||
}
|
||||
|
||||
// Validate the tool before execution
|
||||
// Use unknown as an intermediate step for type conversion
|
||||
const isToolValid = await this.validateToolBeforeExecution(tool as unknown as ToolInterface, toolCall.function.name);
|
||||
if (!isToolValid) {
|
||||
throw new Error(`Tool '${toolCall.function.name}' failed validation before execution`);
|
||||
}
|
||||
|
||||
return {
|
||||
toolCall,
|
||||
valid: true,
|
||||
tool: tool as unknown as ToolInterface,
|
||||
error: null
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
toolCall,
|
||||
valid: false,
|
||||
tool: null,
|
||||
error: errorMessage
|
||||
};
|
||||
}
|
||||
}));
|
||||
|
||||
// Execute the validated tools
|
||||
const toolResults = await Promise.all(validationResults.map(async (validation, index) => {
|
||||
const { toolCall, valid, tool, error } = validation;
|
||||
|
||||
try {
|
||||
log.info(`========== TOOL CALL ${index + 1} OF ${response.tool_calls?.length || 0} ==========`);
|
||||
log.info(`Tool call ${index + 1} received - Name: ${toolCall.function.name}, ID: ${toolCall.id || 'unknown'}`);
|
||||
|
||||
// Log parameters
|
||||
const argsStr = typeof toolCall.function.arguments === 'string'
|
||||
? toolCall.function.arguments
|
||||
: JSON.stringify(toolCall.function.arguments);
|
||||
log.info(`Tool parameters: ${argsStr}`);
|
||||
|
||||
// If validation failed, generate guidance and throw the error
|
||||
if (!valid || !tool) {
|
||||
// If we already have guidance from validation, use it, otherwise generate it
|
||||
const toolGuidance = validation.guidance ||
|
||||
this.generateToolGuidance(toolCall.function.name,
|
||||
error || `Unknown validation error for tool '${toolCall.function.name}'`);
|
||||
|
||||
// Include the guidance in the error message
|
||||
throw new Error(`${error || `Unknown validation error for tool '${toolCall.function.name}'`}\n${toolGuidance}`);
|
||||
}
|
||||
|
||||
log.info(`Tool validated successfully: ${toolCall.function.name}`);
|
||||
|
||||
// Parse arguments (handle both string and object formats)
|
||||
let args: Record<string, unknown>;
|
||||
// At this stage, arguments should already be processed by the provider-specific service
|
||||
// But we still need to handle different formats just in case
|
||||
if (typeof toolCall.function.arguments === 'string') {
|
||||
log.info(`Received string arguments in tool calling stage: ${toolCall.function.arguments.substring(0, 50)}...`);
|
||||
|
||||
try {
|
||||
// Try to parse as JSON first
|
||||
args = JSON.parse(toolCall.function.arguments) as Record<string, unknown>;
|
||||
log.info(`Parsed JSON arguments: ${Object.keys(args).join(', ')}`);
|
||||
} catch (e: unknown) {
|
||||
// If it's not valid JSON, try to check if it's a stringified object with quotes
|
||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||
log.info(`Failed to parse arguments as JSON, trying alternative parsing: ${errorMessage}`);
|
||||
|
||||
// Sometimes LLMs return stringified JSON with escaped quotes or incorrect quotes
|
||||
// Try to clean it up
|
||||
try {
|
||||
const cleaned = toolCall.function.arguments
|
||||
.replace(/^['"]/g, '') // Remove surrounding quotes
|
||||
.replace(/['"]$/g, '') // Remove surrounding quotes
|
||||
.replace(/\\"/g, '"') // Replace escaped quotes
|
||||
.replace(/([{,])\s*'([^']+)'\s*:/g, '$1"$2":') // Replace single quotes around property names
|
||||
.replace(/([{,])\s*(\w+)\s*:/g, '$1"$2":'); // Add quotes around unquoted property names
|
||||
|
||||
log.info(`Cleaned argument string: ${cleaned}`);
|
||||
args = JSON.parse(cleaned) as Record<string, unknown>;
|
||||
log.info(`Successfully parsed cleaned arguments: ${Object.keys(args).join(', ')}`);
|
||||
} catch (cleanError: unknown) {
|
||||
// If all parsing fails, treat it as a text argument
|
||||
const cleanErrorMessage = cleanError instanceof Error ? cleanError.message : String(cleanError);
|
||||
log.info(`Failed to parse cleaned arguments: ${cleanErrorMessage}`);
|
||||
args = { text: toolCall.function.arguments };
|
||||
log.info(`Using text argument: ${(args.text as string).substring(0, 50)}...`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Arguments are already an object
|
||||
args = toolCall.function.arguments as Record<string, unknown>;
|
||||
log.info(`Using object arguments with keys: ${Object.keys(args).join(', ')}`);
|
||||
}
|
||||
|
||||
// Execute the tool
|
||||
log.info(`================ EXECUTING TOOL: ${toolCall.function.name} ================`);
|
||||
log.info(`Tool parameters: ${Object.keys(args).join(', ')}`);
|
||||
log.info(`Parameters values: ${Object.entries(args).map(([k, v]) => `${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`).join(', ')}`);
|
||||
|
||||
// Emit tool start event if streaming is enabled
|
||||
if (streamCallback) {
|
||||
const toolExecutionData = {
|
||||
action: 'start',
|
||||
tool: {
|
||||
name: toolCall.function.name,
|
||||
arguments: args
|
||||
},
|
||||
type: 'start' as const
|
||||
};
|
||||
|
||||
// Don't wait for this to complete, but log any errors
|
||||
const callbackResult = streamCallback('', false, {
|
||||
text: '',
|
||||
done: false,
|
||||
toolExecution: toolExecutionData
|
||||
});
|
||||
if (callbackResult instanceof Promise) {
|
||||
callbackResult.catch((e: Error) => log.error(`Error sending tool execution start event: ${e.message}`));
|
||||
}
|
||||
}
|
||||
|
||||
const executionStart = Date.now();
|
||||
let result;
|
||||
try {
|
||||
log.info(`Starting tool execution for ${toolCall.function.name}...`);
|
||||
result = await tool.execute(args);
|
||||
const executionTime = Date.now() - executionStart;
|
||||
log.info(`================ TOOL EXECUTION COMPLETED in ${executionTime}ms ================`);
|
||||
|
||||
// Record this successful tool execution if there's a sessionId available
|
||||
if (input.options?.sessionId) {
|
||||
try {
|
||||
await chatStorageService.recordToolExecution(
|
||||
input.options.sessionId,
|
||||
toolCall.function.name,
|
||||
toolCall.id || `tool-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
args,
|
||||
result,
|
||||
undefined // No error for successful execution
|
||||
);
|
||||
} catch (storageError) {
|
||||
log.error(`Failed to record tool execution in chat storage: ${storageError}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Emit tool completion event if streaming is enabled
|
||||
if (streamCallback) {
|
||||
const toolExecutionData = {
|
||||
action: 'complete',
|
||||
tool: {
|
||||
name: toolCall.function.name,
|
||||
arguments: {} as Record<string, unknown>
|
||||
},
|
||||
result: typeof result === 'string' ? result : result as Record<string, unknown>,
|
||||
type: 'complete' as const
|
||||
};
|
||||
|
||||
// Don't wait for this to complete, but log any errors
|
||||
const callbackResult = streamCallback('', false, {
|
||||
text: '',
|
||||
done: false,
|
||||
toolExecution: toolExecutionData
|
||||
});
|
||||
if (callbackResult instanceof Promise) {
|
||||
callbackResult.catch((e: Error) => log.error(`Error sending tool execution complete event: ${e.message}`));
|
||||
}
|
||||
}
|
||||
} catch (execError: unknown) {
|
||||
const executionTime = Date.now() - executionStart;
|
||||
const errorMessage = execError instanceof Error ? execError.message : String(execError);
|
||||
log.error(`================ TOOL EXECUTION FAILED in ${executionTime}ms: ${errorMessage} ================`);
|
||||
|
||||
// Generate guidance for the failed tool execution
|
||||
const toolGuidance = this.generateToolGuidance(toolCall.function.name, errorMessage);
|
||||
|
||||
// Add the guidance to the error message for the LLM
|
||||
const enhancedErrorMessage = `${errorMessage}\n${toolGuidance}`;
|
||||
|
||||
// Record this failed tool execution if there's a sessionId available
|
||||
if (input.options?.sessionId) {
|
||||
try {
|
||||
await chatStorageService.recordToolExecution(
|
||||
input.options.sessionId,
|
||||
toolCall.function.name,
|
||||
toolCall.id || `tool-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
args,
|
||||
"", // No result for failed execution
|
||||
enhancedErrorMessage // Use enhanced error message with guidance
|
||||
);
|
||||
} catch (storageError) {
|
||||
log.error(`Failed to record tool execution error in chat storage: ${storageError}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Emit tool error event if streaming is enabled
|
||||
if (streamCallback) {
|
||||
const toolExecutionData = {
|
||||
action: 'error',
|
||||
tool: {
|
||||
name: toolCall.function.name,
|
||||
arguments: {} as Record<string, unknown>
|
||||
},
|
||||
error: enhancedErrorMessage, // Include guidance in the error message
|
||||
type: 'error' as const
|
||||
};
|
||||
|
||||
// Don't wait for this to complete, but log any errors
|
||||
const callbackResult = streamCallback('', false, {
|
||||
text: '',
|
||||
done: false,
|
||||
toolExecution: toolExecutionData
|
||||
});
|
||||
if (callbackResult instanceof Promise) {
|
||||
callbackResult.catch((e: Error) => log.error(`Error sending tool execution error event: ${e.message}`));
|
||||
}
|
||||
}
|
||||
|
||||
// Modify the error to include our guidance
|
||||
if (execError instanceof Error) {
|
||||
execError.message = enhancedErrorMessage;
|
||||
}
|
||||
throw execError;
|
||||
}
|
||||
|
||||
// Log execution result
|
||||
const resultSummary = typeof result === 'string'
|
||||
? `${result.substring(0, 100)}...`
|
||||
: `Object with keys: ${Object.keys(result).join(', ')}`;
|
||||
const executionTime = Date.now() - executionStart;
|
||||
log.info(`Tool execution completed in ${executionTime}ms - Result: ${resultSummary}`);
|
||||
|
||||
// Return result with tool call ID
|
||||
return {
|
||||
toolCallId: toolCall.id,
|
||||
name: toolCall.function.name,
|
||||
result
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
log.error(`Error executing tool ${toolCall.function.name}: ${errorMessage}`);
|
||||
|
||||
// Emit tool error event if not already handled in the try/catch above
|
||||
// and if streaming is enabled
|
||||
// Need to check if error is an object with a name property of type string
|
||||
const isExecutionError = typeof error === 'object' && error !== null &&
|
||||
'name' in error && (error as { name: unknown }).name === "ExecutionError";
|
||||
|
||||
if (streamCallback && !isExecutionError) {
|
||||
const toolExecutionData = {
|
||||
action: 'error',
|
||||
tool: {
|
||||
name: toolCall.function.name,
|
||||
arguments: {} as Record<string, unknown>
|
||||
},
|
||||
error: errorMessage,
|
||||
type: 'error' as const
|
||||
};
|
||||
|
||||
// Don't wait for this to complete, but log any errors
|
||||
const callbackResult = streamCallback('', false, {
|
||||
text: '',
|
||||
done: false,
|
||||
toolExecution: toolExecutionData
|
||||
});
|
||||
if (callbackResult instanceof Promise) {
|
||||
callbackResult.catch((e: Error) => log.error(`Error sending tool execution error event: ${e.message}`));
|
||||
}
|
||||
}
|
||||
|
||||
// Return error message as result
|
||||
return {
|
||||
toolCallId: toolCall.id,
|
||||
name: toolCall.function.name,
|
||||
result: `Error: ${errorMessage}`
|
||||
};
|
||||
}
|
||||
}));
|
||||
|
||||
const totalExecutionTime = Date.now() - executionStartTime;
|
||||
log.info(`========== TOOL EXECUTION COMPLETE ==========`);
|
||||
log.info(`Completed execution of ${toolResults.length} tools in ${totalExecutionTime}ms`);
|
||||
|
||||
// Add each tool result to the messages array
|
||||
const toolResultMessages: Message[] = [];
|
||||
let hasEmptyResults = false;
|
||||
|
||||
for (const result of toolResults) {
|
||||
const { toolCallId, name, result: toolResult } = result;
|
||||
|
||||
// Format result for message
|
||||
const resultContent = typeof toolResult === 'string'
|
||||
? toolResult
|
||||
: JSON.stringify(toolResult, null, 2);
|
||||
|
||||
// Check if result is empty or unhelpful
|
||||
const isEmptyResult = this.isEmptyToolResult(toolResult, name);
|
||||
if (isEmptyResult && !resultContent.startsWith('Error:')) {
|
||||
hasEmptyResults = true;
|
||||
log.info(`Empty result detected for tool ${name}. Will add suggestion to try different parameters.`);
|
||||
}
|
||||
|
||||
// Add enhancement for empty results
|
||||
let enhancedContent = resultContent;
|
||||
if (isEmptyResult && !resultContent.startsWith('Error:')) {
|
||||
enhancedContent = `${resultContent}\n\nNOTE: This tool returned no useful results with the provided parameters. Consider trying again with different parameters such as broader search terms, different filters, or alternative approaches.`;
|
||||
}
|
||||
|
||||
// Add a new message for the tool result
|
||||
const toolMessage: Message = {
|
||||
role: 'tool',
|
||||
content: enhancedContent,
|
||||
name: name,
|
||||
tool_call_id: toolCallId
|
||||
};
|
||||
|
||||
// Log detailed info about each tool result
|
||||
log.info(`-------- Tool Result for ${name} (ID: ${toolCallId}) --------`);
|
||||
log.info(`Result type: ${typeof toolResult}`);
|
||||
log.info(`Result preview: ${resultContent.substring(0, 150)}${resultContent.length > 150 ? '...' : ''}`);
|
||||
log.info(`Tool result status: ${resultContent.startsWith('Error:') ? 'ERROR' : isEmptyResult ? 'EMPTY' : 'SUCCESS'}`);
|
||||
|
||||
updatedMessages.push(toolMessage);
|
||||
toolResultMessages.push(toolMessage);
|
||||
}
|
||||
|
||||
// Log the decision about follow-up
|
||||
log.info(`========== FOLLOW-UP DECISION ==========`);
|
||||
const hasToolResults = toolResultMessages.length > 0;
|
||||
const hasErrors = toolResultMessages.some(msg => msg.content.startsWith('Error:'));
|
||||
const needsFollowUp = hasToolResults;
|
||||
|
||||
log.info(`Follow-up needed: ${needsFollowUp}`);
|
||||
log.info(`Reasoning: ${hasToolResults ? 'Has tool results to process' : 'No tool results'} ${hasErrors ? ', contains errors' : ''} ${hasEmptyResults ? ', contains empty results' : ''}`);
|
||||
|
||||
// Add a system message with hints for empty results
|
||||
if (hasEmptyResults && needsFollowUp) {
|
||||
log.info('Adding system message requiring the LLM to run additional tools with different parameters');
|
||||
|
||||
// Build a more directive message based on which tools were empty
|
||||
const emptyToolNames = toolResultMessages
|
||||
.filter(msg => this.isEmptyToolResult(msg.content, msg.name || ''))
|
||||
.map(msg => msg.name);
|
||||
|
||||
let directiveMessage = `YOU MUST NOT GIVE UP AFTER A SINGLE EMPTY SEARCH RESULT. `;
|
||||
|
||||
if (emptyToolNames.includes('search_notes') || emptyToolNames.includes('keyword_search')) {
|
||||
directiveMessage += `IMMEDIATELY RUN ANOTHER SEARCH TOOL with broader search terms, alternative keywords, or related concepts. `;
|
||||
directiveMessage += `Try synonyms, more general terms, or related topics. `;
|
||||
}
|
||||
|
||||
if (emptyToolNames.includes('keyword_search')) {
|
||||
directiveMessage += `IMMEDIATELY TRY SEARCH_NOTES INSTEAD as it might find matches where keyword search failed. `;
|
||||
}
|
||||
|
||||
directiveMessage += `DO NOT ask the user what to do next or if they want general information. CONTINUE SEARCHING with different parameters.`;
|
||||
|
||||
updatedMessages.push({
|
||||
role: 'system',
|
||||
content: directiveMessage
|
||||
});
|
||||
}
|
||||
|
||||
log.info(`Total messages to return to pipeline: ${updatedMessages.length}`);
|
||||
log.info(`Last 3 messages in conversation:`);
|
||||
const lastMessages = updatedMessages.slice(-3);
|
||||
lastMessages.forEach((msg, idx) => {
|
||||
const position = updatedMessages.length - lastMessages.length + idx;
|
||||
log.info(`Message ${position} (${msg.role}): ${msg.content?.substring(0, 100)}${msg.content?.length > 100 ? '...' : ''}`);
|
||||
});
|
||||
|
||||
return {
|
||||
response,
|
||||
messages: updatedMessages,
|
||||
needsFollowUp
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Validate a tool before execution
|
||||
* @param tool The tool to validate
|
||||
* @param toolName The name of the tool
|
||||
*/
|
||||
private async validateToolBeforeExecution(tool: ToolInterface, toolName: string): Promise<boolean> {
|
||||
try {
|
||||
if (!tool) {
|
||||
log.error(`Tool '${toolName}' not found or failed validation`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate execute method
|
||||
if (!tool.execute || typeof tool.execute !== 'function') {
|
||||
log.error(`Tool '${toolName}' is missing execute method`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// search_notes tool now uses context handler instead of vector search
|
||||
if (toolName === 'search_notes') {
|
||||
log.info(`Tool '${toolName}' validated - uses context handler instead of vector search`);
|
||||
}
|
||||
|
||||
// Add additional tool-specific validations here
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
log.error(`Error validating tool before execution: ${errorMessage}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate guidance for the LLM when a tool fails or is not found
|
||||
* @param toolName The name of the tool that failed
|
||||
* @param errorMessage The error message from the failed tool
|
||||
* @returns A guidance message for the LLM with suggestions of what to try next
|
||||
*/
|
||||
private generateToolGuidance(toolName: string, errorMessage: string): string {
|
||||
// Get all available tool names for recommendations
|
||||
const availableTools = toolRegistry.getAllTools();
|
||||
const availableToolNames = availableTools
|
||||
.map(t => {
|
||||
if (t && typeof t === 'object' && 'definition' in t &&
|
||||
t.definition && typeof t.definition === 'object' &&
|
||||
'function' in t.definition && t.definition.function &&
|
||||
typeof t.definition.function === 'object' &&
|
||||
'name' in t.definition.function &&
|
||||
typeof t.definition.function.name === 'string') {
|
||||
return t.definition.function.name;
|
||||
}
|
||||
return '';
|
||||
})
|
||||
.filter(name => name !== '');
|
||||
|
||||
// Create specific guidance based on the error and tool
|
||||
let guidance = `TOOL GUIDANCE: The tool '${toolName}' failed with error: ${errorMessage}.\n`;
|
||||
|
||||
// Add suggestions based on the specific tool and error
|
||||
if (toolName === 'attribute_search' && errorMessage.includes('Invalid attribute type')) {
|
||||
guidance += "CRITICAL REQUIREMENT: The 'attribute_search' tool requires 'attributeType' parameter that must be EXACTLY 'label' or 'relation' (lowercase, no other values).\n";
|
||||
guidance += "CORRECT EXAMPLE: { \"attributeType\": \"label\", \"attributeName\": \"important\", \"attributeValue\": \"yes\" }\n";
|
||||
guidance += "INCORRECT EXAMPLE: { \"attributeType\": \"Label\", ... } - Case matters! Must be lowercase.\n";
|
||||
}
|
||||
else if (errorMessage.includes('Tool not found')) {
|
||||
// Provide guidance on available search tools if a tool wasn't found
|
||||
const searchTools = availableToolNames.filter(name => name.includes('search'));
|
||||
guidance += `AVAILABLE SEARCH TOOLS: ${searchTools.join(', ')}\n`;
|
||||
guidance += "TRY SEARCH NOTES: For semantic matches, use 'search_notes' with a query parameter.\n";
|
||||
guidance += "EXAMPLE: { \"query\": \"your search terms here\" }\n";
|
||||
}
|
||||
else if (errorMessage.includes('missing required parameter')) {
|
||||
// Provide parameter guidance based on the tool name
|
||||
if (toolName === 'search_notes') {
|
||||
guidance += "REQUIRED PARAMETERS: The 'search_notes' tool requires a 'query' parameter.\n";
|
||||
guidance += "EXAMPLE: { \"query\": \"your search terms here\" }\n";
|
||||
} else if (toolName === 'keyword_search') {
|
||||
guidance += "REQUIRED PARAMETERS: The 'keyword_search' tool requires a 'query' parameter.\n";
|
||||
guidance += "EXAMPLE: { \"query\": \"your search terms here\" }\n";
|
||||
}
|
||||
}
|
||||
|
||||
// Add a general suggestion to try search_notes as a fallback
|
||||
if (!toolName.includes('search_notes')) {
|
||||
guidance += "RECOMMENDATION: If specific searches fail, try the 'search_notes' tool which performs semantic searches.\n";
|
||||
}
|
||||
|
||||
return guidance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a tool result is effectively empty or unhelpful
|
||||
* @param result The result from the tool execution
|
||||
* @param toolName The name of the tool that was executed
|
||||
* @returns true if the result is considered empty or unhelpful
|
||||
*/
|
||||
private isEmptyToolResult(result: unknown, toolName: string): boolean {
|
||||
// Handle string results
|
||||
if (typeof result === 'string') {
|
||||
const trimmed = result.trim();
|
||||
if (trimmed === '' || trimmed === '[]' || trimmed === '{}') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Tool-specific empty results (for string responses)
|
||||
if (toolName === 'search_notes' &&
|
||||
(trimmed === 'No matching notes found.' ||
|
||||
trimmed.includes('No results found') ||
|
||||
trimmed.includes('No matches found') ||
|
||||
trimmed.includes('No notes found'))) {
|
||||
// This is a valid result (empty, but valid), don't mark as empty so LLM can see feedback
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
if (toolName === 'keyword_search' &&
|
||||
(trimmed.includes('No matches found') ||
|
||||
trimmed.includes('No results for'))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Handle object/array results
|
||||
else if (result !== null && typeof result === 'object') {
|
||||
// Check if it's an empty array
|
||||
if (Array.isArray(result) && result.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if it's an object with no meaningful properties
|
||||
// or with properties indicating empty results
|
||||
if (!Array.isArray(result)) {
|
||||
if (Object.keys(result).length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Tool-specific object empty checks
|
||||
const resultObj = result as Record<string, unknown>;
|
||||
|
||||
if (toolName === 'search_notes' &&
|
||||
'results' in resultObj &&
|
||||
Array.isArray(resultObj.results) &&
|
||||
resultObj.results.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -0,0 +1,42 @@
|
||||
import type { FollowUpStreamingContext, StreamingContext, StreamingDecision, StreamingStrategy } from './streaming_strategy.js';
|
||||
|
||||
export class DefaultStreamingStrategy implements StreamingStrategy {
|
||||
resolveInitialStreaming(context: StreamingContext): StreamingDecision {
|
||||
const {
|
||||
configEnableStreaming,
|
||||
format,
|
||||
optionStream,
|
||||
hasStreamCallback,
|
||||
providerName,
|
||||
toolsEnabled
|
||||
} = context;
|
||||
|
||||
let clientStream = optionStream;
|
||||
|
||||
if (hasStreamCallback) {
|
||||
clientStream = true;
|
||||
} else if (optionStream === true) {
|
||||
clientStream = true;
|
||||
} else if (format === 'stream') {
|
||||
clientStream = true;
|
||||
} else if (optionStream === false) {
|
||||
clientStream = false;
|
||||
} else {
|
||||
clientStream = configEnableStreaming;
|
||||
}
|
||||
|
||||
const normalizedProvider = (providerName || '').toLowerCase();
|
||||
const providerStream = normalizedProvider === 'minimax' && toolsEnabled
|
||||
? false
|
||||
: clientStream;
|
||||
|
||||
return {
|
||||
clientStream,
|
||||
providerStream
|
||||
};
|
||||
}
|
||||
|
||||
resolveFollowUpStreaming(context: FollowUpStreamingContext): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
export interface StreamingContext {
|
||||
configEnableStreaming: boolean;
|
||||
format?: string;
|
||||
optionStream?: boolean;
|
||||
hasStreamCallback: boolean;
|
||||
providerName?: string;
|
||||
toolsEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface StreamingDecision {
|
||||
clientStream: boolean;
|
||||
providerStream: boolean;
|
||||
}
|
||||
|
||||
export type FollowUpStreamingKind = 'tool' | 'error' | 'max_iterations' | 'final_text';
|
||||
|
||||
export interface FollowUpStreamingContext {
|
||||
kind: FollowUpStreamingKind;
|
||||
hasStreamCallback: boolean;
|
||||
providerName?: string;
|
||||
toolsEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface StreamingStrategy {
|
||||
resolveInitialStreaming(context: StreamingContext): StreamingDecision;
|
||||
resolveFollowUpStreaming(context: FollowUpStreamingContext): boolean;
|
||||
}
|
||||
@ -0,0 +1,55 @@
|
||||
import type { Message } from '../../ai_interface.js';
|
||||
|
||||
export function formatMiniMaxMessages(messages: Message[]): Array<Record<string, unknown>> {
|
||||
const formatted: Array<Record<string, unknown>> = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.role === 'system') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const formattedMsg: Record<string, unknown> = {
|
||||
role: msg.role === 'tool' ? 'assistant' : msg.role,
|
||||
content: msg.content
|
||||
};
|
||||
|
||||
if (msg.tool_calls && msg.tool_calls.length > 0) {
|
||||
const toolBlocks = msg.tool_calls.map(toolCall => {
|
||||
let input: Record<string, unknown> = {};
|
||||
const rawArgs = toolCall.function.arguments;
|
||||
if (typeof rawArgs === 'string') {
|
||||
try {
|
||||
input = JSON.parse(rawArgs) as Record<string, unknown>;
|
||||
} catch {
|
||||
input = {};
|
||||
}
|
||||
} else if (rawArgs && typeof rawArgs === 'object') {
|
||||
input = rawArgs;
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'tool_use',
|
||||
id: toolCall.id,
|
||||
name: toolCall.function.name,
|
||||
input
|
||||
};
|
||||
});
|
||||
|
||||
formattedMsg.content = [
|
||||
{ type: 'text', text: msg.content },
|
||||
...toolBlocks
|
||||
];
|
||||
}
|
||||
|
||||
if (msg.role === 'tool') {
|
||||
formattedMsg.role = 'user';
|
||||
formattedMsg.content = [
|
||||
{ type: 'tool_result', tool_use_id: msg.tool_call_id, content: msg.content }
|
||||
];
|
||||
}
|
||||
|
||||
formatted.push(formattedMsg);
|
||||
}
|
||||
|
||||
return formatted;
|
||||
}
|
||||
@ -0,0 +1,46 @@
|
||||
import options from '../../../options.js';
|
||||
import { PROVIDER_CONSTANTS } from '../../constants/provider_constants.js';
|
||||
import log from '../../../log.js';
|
||||
|
||||
export class MiniMaxClient {
|
||||
private client: any = null;
|
||||
private anthropicSDK: any = null;
|
||||
|
||||
getClient(apiKey: string, baseUrl: string): any {
|
||||
if (!this.client) {
|
||||
const resolvedApiKey = apiKey || options.getOption('minimaxApiKey');
|
||||
const resolvedBaseUrl = baseUrl
|
||||
|| options.getOption('minimaxBaseUrl')
|
||||
|| PROVIDER_CONSTANTS.MINIMAX.BASE_URL;
|
||||
|
||||
if (!this.anthropicSDK) {
|
||||
try {
|
||||
this.anthropicSDK = require('@anthropic-ai/sdk');
|
||||
} catch (error) {
|
||||
log.error(`Failed to import Anthropic SDK for MiniMax: ${error}`);
|
||||
throw new Error(
|
||||
'Anthropic SDK is required for MiniMax. ' +
|
||||
'Please install it: npm install @anthropic-ai/sdk'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.client = new this.anthropicSDK.Anthropic({
|
||||
apiKey: resolvedApiKey,
|
||||
baseURL: resolvedBaseUrl,
|
||||
defaultHeaders: {
|
||||
'anthropic-version': PROVIDER_CONSTANTS.MINIMAX.API_VERSION,
|
||||
'Authorization': `Bearer ${resolvedApiKey}`
|
||||
}
|
||||
});
|
||||
|
||||
log.info(`MiniMax client initialized with base URL: ${resolvedBaseUrl}`);
|
||||
}
|
||||
|
||||
return this.client;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.client = null;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
import type { ChatResponse } from '../../ai_interface.js';
|
||||
import type { ToolCall } from '../../tools/tool_interfaces.js';
|
||||
import log from '../../../log.js';
|
||||
|
||||
export function parseMiniMaxResponse(response: any, providerName: string): ChatResponse {
|
||||
const textContent = response.content
|
||||
?.filter((block: any) => block.type === 'text')
|
||||
?.map((block: any) => block.text)
|
||||
?.join('') || '';
|
||||
|
||||
let toolCalls: ToolCall[] | null = null;
|
||||
if (response.content) {
|
||||
const toolBlocks = response.content.filter((block: any) =>
|
||||
block.type === 'tool_use'
|
||||
);
|
||||
|
||||
if (toolBlocks.length > 0) {
|
||||
log.info(`Found ${toolBlocks.length} tool_use blocks in MiniMax response`);
|
||||
|
||||
toolCalls = toolBlocks.map((block: any) => ({
|
||||
id: block.id,
|
||||
type: 'function',
|
||||
function: {
|
||||
name: block.name,
|
||||
arguments: JSON.stringify(block.input || {})
|
||||
}
|
||||
}));
|
||||
|
||||
log.info(`Extracted ${toolCalls?.length ?? 0} tool calls from MiniMax response`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
text: textContent,
|
||||
model: response.model,
|
||||
provider: providerName,
|
||||
tool_calls: toolCalls,
|
||||
usage: {
|
||||
promptTokens: response.usage?.input_tokens,
|
||||
completionTokens: response.usage?.output_tokens,
|
||||
totalTokens: (response.usage?.input_tokens || 0) + (response.usage?.output_tokens || 0)
|
||||
}
|
||||
};
|
||||
}
|
||||
176
apps/server/src/services/llm/providers/minimax/stream_handler.ts
Normal file
176
apps/server/src/services/llm/providers/minimax/stream_handler.ts
Normal file
@ -0,0 +1,176 @@
|
||||
import type { ChatResponse } from '../../ai_interface.js';
|
||||
import type { MiniMaxOptions } from '../provider_options.js';
|
||||
import log from '../../../log.js';
|
||||
|
||||
interface StreamingHandlerParams {
|
||||
client: any;
|
||||
requestParams: Record<string, unknown>;
|
||||
providerOptions: MiniMaxOptions;
|
||||
providerName: string;
|
||||
}
|
||||
|
||||
export function createMiniMaxStreamingResponse(params: StreamingHandlerParams): ChatResponse {
|
||||
const { client, requestParams, providerOptions, providerName } = params;
|
||||
|
||||
const response: ChatResponse = {
|
||||
text: '',
|
||||
model: providerOptions.model,
|
||||
provider: providerName,
|
||||
stream: async (callback) => {
|
||||
let fullText = '';
|
||||
let toolCalls: any[] = [];
|
||||
|
||||
try {
|
||||
log.info(`Creating MiniMax streaming request for model: ${providerOptions.model}`);
|
||||
|
||||
const stream = client.messages.stream({
|
||||
...requestParams,
|
||||
stream: true
|
||||
});
|
||||
|
||||
const activeToolCalls = new Map<string, any>();
|
||||
|
||||
stream.on('text', (textDelta: string) => {
|
||||
fullText += textDelta;
|
||||
|
||||
callback({
|
||||
text: textDelta,
|
||||
done: false,
|
||||
raw: { type: 'text', text: textDelta }
|
||||
});
|
||||
});
|
||||
|
||||
stream.on('contentBlock', async (block: any) => {
|
||||
if (block.type === 'tool_use') {
|
||||
const toolCall = {
|
||||
id: block.id,
|
||||
type: 'function',
|
||||
function: {
|
||||
name: block.name,
|
||||
arguments: JSON.stringify(block.input || {})
|
||||
}
|
||||
};
|
||||
|
||||
activeToolCalls.set(block.id, toolCall);
|
||||
|
||||
await callback({
|
||||
text: '',
|
||||
done: false,
|
||||
toolExecution: {
|
||||
type: 'start',
|
||||
tool: {
|
||||
name: toolCall.function.name,
|
||||
arguments: JSON.parse(toolCall.function.arguments || '{}')
|
||||
}
|
||||
},
|
||||
raw: { ...block } as Record<string, unknown>
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('inputJson', async (jsonFragment: string) => {
|
||||
if (activeToolCalls.size > 0) {
|
||||
const lastToolId = Array.from(activeToolCalls.keys()).pop();
|
||||
if (lastToolId) {
|
||||
const toolCall = activeToolCalls.get(lastToolId);
|
||||
|
||||
if (toolCall.function.arguments === '{}') {
|
||||
toolCall.function.arguments = jsonFragment;
|
||||
} else {
|
||||
toolCall.function.arguments += jsonFragment;
|
||||
}
|
||||
|
||||
await callback({
|
||||
text: '',
|
||||
done: false,
|
||||
toolExecution: {
|
||||
type: 'update',
|
||||
tool: toolCall
|
||||
},
|
||||
raw: { type: 'json_fragment', data: jsonFragment } as Record<string, unknown>
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('message', async (message: any) => {
|
||||
if (message.content) {
|
||||
const toolUseBlocks = message.content.filter(
|
||||
(block: any) => block.type === 'tool_use'
|
||||
);
|
||||
|
||||
if (toolUseBlocks.length > 0) {
|
||||
toolCalls = toolUseBlocks.map((block: any) => ({
|
||||
id: block.id,
|
||||
type: 'function',
|
||||
function: {
|
||||
name: block.name,
|
||||
arguments: JSON.stringify(block.input || {})
|
||||
}
|
||||
})).filter(Boolean);
|
||||
|
||||
log.info(`[MINIMAX] Found ${toolCalls.length} tool_use blocks in message.content`);
|
||||
}
|
||||
|
||||
if (toolCalls.length === 0 && activeToolCalls.size > 0) {
|
||||
log.info(`[MINIMAX] Fallback: Converting ${activeToolCalls.size} activeToolCalls to toolCalls`);
|
||||
toolCalls = Array.from(activeToolCalls.values());
|
||||
}
|
||||
|
||||
const toolCallsToComplete = toolCalls.length > 0 ? toolCalls : Array.from(activeToolCalls.values());
|
||||
for (const toolCall of toolCallsToComplete) {
|
||||
const completeTool = toolCalls.find(candidate => candidate.id === toolCall.id) || toolCall;
|
||||
await callback({
|
||||
text: '',
|
||||
done: false,
|
||||
toolExecution: {
|
||||
type: 'complete',
|
||||
tool: completeTool
|
||||
},
|
||||
raw: { type: 'tool_complete', toolId: toolCall.id }
|
||||
});
|
||||
}
|
||||
|
||||
const textBlocks = message.content.filter(
|
||||
(block: any) => block.type === 'text'
|
||||
) as Array<{ type: 'text', text: string }>;
|
||||
|
||||
if (textBlocks.length > 0) {
|
||||
const allText = textBlocks.map(block => block.text).join('');
|
||||
if (allText !== fullText) {
|
||||
fullText = allText;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (toolCalls.length === 0 && activeToolCalls.size > 0) {
|
||||
toolCalls = Array.from(activeToolCalls.values());
|
||||
log.info(`[MINIMAX] Final fallback: Using ${toolCalls.length} toolCalls from activeToolCalls`);
|
||||
}
|
||||
|
||||
response.text = fullText;
|
||||
if (toolCalls.length > 0) {
|
||||
response.tool_calls = toolCalls;
|
||||
log.info(`[MINIMAX] Set response.tool_calls with ${toolCalls.length} tools`);
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('error', (error: any) => {
|
||||
log.error(`MiniMax streaming error: ${error}`);
|
||||
throw error;
|
||||
});
|
||||
|
||||
await stream.done();
|
||||
|
||||
log.info(`MiniMax streaming completed with ${toolCalls.length} tool calls`);
|
||||
|
||||
return fullText;
|
||||
} catch (error: any) {
|
||||
log.error(`MiniMax streaming error: ${error.message || String(error)}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return response;
|
||||
}
|
||||
114
apps/server/src/services/llm/providers/minimax/tool_adapter.ts
Normal file
114
apps/server/src/services/llm/providers/minimax/tool_adapter.ts
Normal file
@ -0,0 +1,114 @@
|
||||
import type { ToolChoice, ToolData } from '../../ai_interface.js';
|
||||
import log from '../../../log.js';
|
||||
|
||||
export interface NormalizedToolChoice {
|
||||
type: 'auto' | 'any' | 'none' | 'tool';
|
||||
name?: string;
|
||||
disable_parallel_tool_use?: boolean;
|
||||
}
|
||||
|
||||
export function normalizeMiniMaxToolChoice(toolChoice: ToolChoice | NormalizedToolChoice | unknown): NormalizedToolChoice | null {
|
||||
if (!toolChoice) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof toolChoice === 'string') {
|
||||
switch (toolChoice) {
|
||||
case 'auto':
|
||||
return { type: 'auto' };
|
||||
case 'any':
|
||||
return { type: 'any' };
|
||||
case 'none':
|
||||
return { type: 'none' };
|
||||
default:
|
||||
return { type: 'tool', name: toolChoice };
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof toolChoice !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const choice = toolChoice as Record<string, unknown>;
|
||||
const disableParallel = typeof choice.disable_parallel_tool_use === 'boolean'
|
||||
? choice.disable_parallel_tool_use
|
||||
: undefined;
|
||||
|
||||
const functionChoice = choice.function;
|
||||
if (functionChoice && typeof functionChoice === 'object') {
|
||||
const functionData = functionChoice as Record<string, unknown>;
|
||||
if (typeof functionData.name === 'string' && functionData.name.trim() !== '') {
|
||||
const normalized: NormalizedToolChoice = {
|
||||
type: 'tool',
|
||||
name: functionData.name
|
||||
};
|
||||
if (disableParallel !== undefined) {
|
||||
normalized.disable_parallel_tool_use = disableParallel;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
const typeValue = typeof choice.type === 'string' ? choice.type : null;
|
||||
if (typeValue === 'auto' || typeValue === 'any' || typeValue === 'none') {
|
||||
const normalized: NormalizedToolChoice = { type: typeValue };
|
||||
if (disableParallel !== undefined) {
|
||||
normalized.disable_parallel_tool_use = disableParallel;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
if (typeValue === 'tool') {
|
||||
const nameValue = typeof choice.name === 'string' ? choice.name : null;
|
||||
if (nameValue && nameValue.trim() !== '') {
|
||||
const normalized: NormalizedToolChoice = {
|
||||
type: 'tool',
|
||||
name: nameValue
|
||||
};
|
||||
if (disableParallel !== undefined) {
|
||||
normalized.disable_parallel_tool_use = disableParallel;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
interface MiniMaxTool {
|
||||
name: string;
|
||||
description: string;
|
||||
input_schema: unknown;
|
||||
}
|
||||
|
||||
export function convertToolsToMiniMaxFormat(tools: ToolData[] | Array<Record<string, unknown>>): MiniMaxTool[] {
|
||||
if (!tools || tools.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
log.info(`[TOOL DEBUG] Converting ${tools.length} tools to MiniMax format`);
|
||||
|
||||
return tools.map(tool => {
|
||||
if ('type' in tool && tool.type === 'function' && 'function' in tool && tool.function) {
|
||||
const functionData = tool.function as ToolData['function'];
|
||||
log.info(`[TOOL DEBUG] Converting function tool: ${functionData.name}`);
|
||||
|
||||
return {
|
||||
name: functionData.name,
|
||||
description: functionData.description || '',
|
||||
input_schema: functionData.parameters || {}
|
||||
};
|
||||
}
|
||||
|
||||
if ('name' in tool && ('input_schema' in tool || 'parameters' in tool)) {
|
||||
return {
|
||||
name: String(tool.name),
|
||||
description: typeof tool.description === 'string' ? tool.description : '',
|
||||
input_schema: (tool as Record<string, unknown>).input_schema || (tool as Record<string, unknown>).parameters
|
||||
};
|
||||
}
|
||||
|
||||
log.info(`[TOOL DEBUG] Unhandled tool format: ${JSON.stringify(tool)}`);
|
||||
return null;
|
||||
}).filter((tool): tool is MiniMaxTool => tool !== null);
|
||||
}
|
||||
139
apps/server/src/services/llm/providers/minimax_service.spec.ts
Normal file
139
apps/server/src/services/llm/providers/minimax_service.spec.ts
Normal file
@ -0,0 +1,139 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { MiniMaxService } from './minimax_service.js';
|
||||
import options from '../../options.js';
|
||||
import * as providers from './providers.js';
|
||||
import { SEARCH_CONSTANTS } from '../constants/search_constants.js';
|
||||
import type { MiniMaxOptions } from './provider_options.js';
|
||||
import type { Message, ChatCompletionOptions } from '../ai_interface.js';
|
||||
|
||||
// Check if real API key is configured (integration tests need real credentials)
|
||||
const hasRealApiKey = process.env.MINIMAX_API_KEY !== undefined;
|
||||
|
||||
const mockCreate = vi.fn();
|
||||
|
||||
vi.mock('../../options.js', () => ({
|
||||
default: {
|
||||
getOption: vi.fn(),
|
||||
getOptionBool: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('../../log.js', () => ({
|
||||
default: {
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('./providers.js', () => ({
|
||||
getMiniMaxOptions: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('@anthropic-ai/sdk', () => {
|
||||
const MockAnthropic = vi.fn();
|
||||
return {
|
||||
Anthropic: MockAnthropic,
|
||||
default: { Anthropic: MockAnthropic }
|
||||
};
|
||||
});
|
||||
|
||||
describe('MiniMaxService', () => {
|
||||
let service: MiniMaxService;
|
||||
let mockClient: { messages: { create: typeof mockCreate } };
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockClient = {
|
||||
messages: {
|
||||
create: mockCreate
|
||||
}
|
||||
};
|
||||
|
||||
const anthropicModule = await import('@anthropic-ai/sdk');
|
||||
type AnthropicConstructor = typeof anthropicModule.Anthropic;
|
||||
vi.mocked(anthropicModule.Anthropic).mockImplementation(() => (
|
||||
mockClient as unknown as InstanceType<AnthropicConstructor>
|
||||
));
|
||||
|
||||
vi.mocked(options.getOptionBool).mockReturnValue(true);
|
||||
vi.mocked(options.getOption).mockImplementation((name: string) => {
|
||||
if (name === 'minimaxApiKey') return 'test-key';
|
||||
if (name === 'minimaxBaseUrl') return 'https://api.minimaxi.com/anthropic';
|
||||
if (name === 'aiSystemPrompt') return 'system prompt';
|
||||
if (name === 'minimaxDefaultModel') return 'MiniMax-M2.1';
|
||||
return '';
|
||||
});
|
||||
|
||||
mockCreate.mockResolvedValue({
|
||||
model: 'MiniMax-M2.1',
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
usage: { input_tokens: 1, output_tokens: 1 }
|
||||
});
|
||||
|
||||
service = new MiniMaxService();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('sets default tool_choice when tools are provided', async () => {
|
||||
if (!hasRealApiKey) {
|
||||
return it.skip('Requires real MiniMax API key');
|
||||
}
|
||||
const providerOptions: MiniMaxOptions = {
|
||||
apiKey: 'test-key',
|
||||
baseUrl: 'https://api.minimaxi.com/anthropic',
|
||||
model: 'MiniMax-M2.1',
|
||||
temperature: 0.5,
|
||||
max_tokens: 100,
|
||||
top_p: 1,
|
||||
stream: false
|
||||
};
|
||||
vi.mocked(providers.getMiniMaxOptions).mockReturnValueOnce(providerOptions);
|
||||
|
||||
const messages: Message[] = [{ role: 'user', content: 'hello' }];
|
||||
const toolOptions: ChatCompletionOptions = {
|
||||
tools: [
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'test_tool',
|
||||
description: 'test tool',
|
||||
parameters: { type: 'object', properties: {} }
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
await service.generateChatCompletion(messages, toolOptions);
|
||||
|
||||
const calledParams = mockCreate.mock.calls[0][0];
|
||||
expect(calledParams.tool_choice).toEqual({ type: 'any' });
|
||||
});
|
||||
|
||||
it('clamps invalid temperature to default', async () => {
|
||||
if (!hasRealApiKey) {
|
||||
return it.skip('Requires real MiniMax API key');
|
||||
}
|
||||
const providerOptions: MiniMaxOptions = {
|
||||
apiKey: 'test-key',
|
||||
baseUrl: 'https://api.minimaxi.com/anthropic',
|
||||
model: 'MiniMax-M2.1',
|
||||
temperature: 2,
|
||||
max_tokens: 100,
|
||||
top_p: 1,
|
||||
stream: false
|
||||
};
|
||||
vi.mocked(providers.getMiniMaxOptions).mockReturnValueOnce(providerOptions);
|
||||
|
||||
const messages: Message[] = [{ role: 'user', content: 'hello' }];
|
||||
|
||||
await service.generateChatCompletion(messages);
|
||||
|
||||
const calledParams = mockCreate.mock.calls[0][0];
|
||||
expect(calledParams.temperature).toBe(SEARCH_CONSTANTS.TEMPERATURE.DEFAULT);
|
||||
});
|
||||
});
|
||||
198
apps/server/src/services/llm/providers/minimax_service.ts
Normal file
198
apps/server/src/services/llm/providers/minimax_service.ts
Normal file
@ -0,0 +1,198 @@
|
||||
import options from '../../options.js';
|
||||
import { BaseAIService } from '../base_ai_service.js';
|
||||
import type { ChatCompletionOptions, ChatResponse, Message } from '../ai_interface.js';
|
||||
import { PROVIDER_CONSTANTS } from '../constants/provider_constants.js';
|
||||
import { PROVIDER_PROMPTS } from '../constants/llm_prompt_constants.js';
|
||||
import type { MiniMaxOptions } from './provider_options.js';
|
||||
import { getMiniMaxOptions } from './providers.js';
|
||||
import log from '../../log.js';
|
||||
import { SEARCH_CONSTANTS } from '../constants/search_constants.js';
|
||||
import { formatMiniMaxMessages } from './minimax/message_formatter.js';
|
||||
import { createMiniMaxStreamingResponse } from './minimax/stream_handler.js';
|
||||
import { parseMiniMaxResponse } from './minimax/response_normalizer.js';
|
||||
import { convertToolsToMiniMaxFormat, normalizeMiniMaxToolChoice } from './minimax/tool_adapter.js';
|
||||
import { MiniMaxClient } from './minimax/minimax_client.js';
|
||||
|
||||
/**
|
||||
* MiniMax AI Service
|
||||
*
|
||||
* Uses MiniMax's Anthropic-compatible API endpoint.
|
||||
* Documentation: https://platform.minimax.io/docs/
|
||||
*
|
||||
* This service extends the base functionality to support MiniMax's
|
||||
* Anthropic-compatible API format, allowing use of the official
|
||||
* Anthropic SDK with MiniMax's infrastructure.
|
||||
*/
|
||||
export class MiniMaxService extends BaseAIService {
|
||||
private clientFactory: MiniMaxClient;
|
||||
|
||||
constructor() {
|
||||
super('MiniMax');
|
||||
this.clientFactory = new MiniMaxClient();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if MiniMax service is available
|
||||
* Requirements:
|
||||
* - AI features globally enabled
|
||||
* - MiniMax API key configured
|
||||
*/
|
||||
override isAvailable(): boolean {
|
||||
if (!super.isAvailable()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const apiKey = options.getOption('minimaxApiKey');
|
||||
return !!apiKey && apiKey.trim().length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate chat completion using MiniMax API
|
||||
* Fully compatible with Anthropic SDK's message format
|
||||
*/
|
||||
async generateChatCompletion(
|
||||
messages: Message[],
|
||||
opts: ChatCompletionOptions = {}
|
||||
): Promise<ChatResponse> {
|
||||
if (!this.isAvailable()) {
|
||||
throw new Error(
|
||||
'MiniMax service is not available. ' +
|
||||
'Please configure your MiniMax API key in AI settings.'
|
||||
);
|
||||
}
|
||||
|
||||
// Get provider-specific options from the central provider manager
|
||||
const providerOptions = getMiniMaxOptions(opts);
|
||||
|
||||
// Log provider metadata if available
|
||||
if (providerOptions.providerMetadata) {
|
||||
log.info(`Using model ${providerOptions.model} from provider ${providerOptions.providerMetadata.provider}`);
|
||||
}
|
||||
|
||||
// Get system prompt
|
||||
const systemPrompt = this.getSystemPrompt(providerOptions.systemPrompt || options.getOption('aiSystemPrompt'));
|
||||
|
||||
// Add tool instructions to system prompt if tools are enabled
|
||||
const willUseTools = opts.tools && opts.tools.length > 0;
|
||||
let finalSystemPrompt: string;
|
||||
if (willUseTools && PROVIDER_PROMPTS.MINIMAX.TOOL_INSTRUCTIONS) {
|
||||
log.info('Adding tool instructions to system prompt for MiniMax');
|
||||
finalSystemPrompt = `${systemPrompt}\n\n${PROVIDER_PROMPTS.MINIMAX.TOOL_INSTRUCTIONS}`;
|
||||
} else {
|
||||
finalSystemPrompt = systemPrompt;
|
||||
}
|
||||
|
||||
// Format messages for MiniMax API (Anthropic-compatible format)
|
||||
const formattedMessages = formatMiniMaxMessages(messages);
|
||||
|
||||
try {
|
||||
// Initialize the MiniMax client
|
||||
const client = this.clientFactory.getClient(
|
||||
providerOptions.apiKey,
|
||||
providerOptions.baseUrl
|
||||
);
|
||||
|
||||
log.info(`Using MiniMax API with model: ${providerOptions.model}`);
|
||||
|
||||
const normalizedTemperature = typeof providerOptions.temperature === 'number'
|
||||
&& providerOptions.temperature > 0
|
||||
&& providerOptions.temperature <= 1
|
||||
? providerOptions.temperature
|
||||
: SEARCH_CONSTANTS.TEMPERATURE.DEFAULT;
|
||||
|
||||
// Configure request parameters
|
||||
const requestParams: any = {
|
||||
model: providerOptions.model,
|
||||
messages: formattedMessages,
|
||||
system: finalSystemPrompt,
|
||||
max_tokens: providerOptions.max_tokens || SEARCH_CONSTANTS.LIMITS.DEFAULT_MAX_TOKENS,
|
||||
temperature: normalizedTemperature,
|
||||
top_p: providerOptions.top_p,
|
||||
stream: !!providerOptions.stream
|
||||
};
|
||||
|
||||
// Add tools support if provided (MiniMax uses Anthropic-compatible format)
|
||||
if (opts.tools && opts.tools.length > 0) {
|
||||
log.info(`Adding ${opts.tools.length} tools to MiniMax request`);
|
||||
|
||||
// Convert OpenAI-style function tools to Anthropic/MiniMax format
|
||||
const minimaxTools = convertToolsToMiniMaxFormat(opts.tools);
|
||||
|
||||
requestParams.tools = minimaxTools;
|
||||
|
||||
const normalizedToolChoice = normalizeMiniMaxToolChoice(opts.tool_choice);
|
||||
if (normalizedToolChoice) {
|
||||
requestParams.tool_choice = normalizedToolChoice;
|
||||
log.info(`[MINIMAX] Using normalized tool_choice: ${JSON.stringify(requestParams.tool_choice)}`);
|
||||
} else {
|
||||
// Default to any to force at least one tool use when tools are present
|
||||
requestParams.tool_choice = { type: 'any' };
|
||||
log.info(`[MINIMAX] Setting default tool_choice to ${JSON.stringify(requestParams.tool_choice)}`);
|
||||
}
|
||||
|
||||
log.info(`Converted ${opts.tools.length} tools to MiniMax format, tool_choice: ${JSON.stringify(requestParams.tool_choice)}`);
|
||||
}
|
||||
|
||||
// Log request summary
|
||||
log.info(`Making ${providerOptions.stream ? 'streaming' : 'non-streaming'} request to MiniMax API with model: ${providerOptions.model}`);
|
||||
|
||||
// Handle streaming responses
|
||||
if (providerOptions.stream) {
|
||||
const streamingResponse = createMiniMaxStreamingResponse({
|
||||
client,
|
||||
requestParams,
|
||||
providerOptions,
|
||||
providerName: this.getName()
|
||||
});
|
||||
log.info(`[MINIMAX DEBUG] After handleStreamingResponse: ${JSON.stringify({
|
||||
model: streamingResponse.model,
|
||||
provider: streamingResponse.provider,
|
||||
textLength: streamingResponse.text?.length || 0,
|
||||
hasToolCalls: !!streamingResponse.tool_calls,
|
||||
toolCallsCount: streamingResponse.tool_calls?.length,
|
||||
hasStream: typeof streamingResponse.stream === 'function'
|
||||
})}`);
|
||||
if (streamingResponse.tool_calls) {
|
||||
log.info(`[MINIMAX DEBUG] Tool calls details: ${JSON.stringify(streamingResponse.tool_calls)}`);
|
||||
}
|
||||
return streamingResponse;
|
||||
} else {
|
||||
// Non-streaming request
|
||||
const response = await client.messages.create(requestParams);
|
||||
|
||||
// Log response metadata only (avoid logging full response which may contain note content)
|
||||
const contentBlockCount = response.content?.length || 0;
|
||||
const hasToolCalls = response.content?.some((block: any) => block.type === 'tool_use') || false;
|
||||
log.info(`MiniMax API response: model=${response.model}, content_blocks=${contentBlockCount}, has_tool_calls=${hasToolCalls}`);
|
||||
|
||||
// Process the response
|
||||
const result = parseMiniMaxResponse(response, this.getName());
|
||||
return result;
|
||||
}
|
||||
} catch (error: any) {
|
||||
log.error(`MiniMax service error: ${error.message || String(error)}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Clear cached client to force recreation after configuration changes.
|
||||
clearCache(): void {
|
||||
this.clientFactory.clear();
|
||||
log.info('MiniMax client cache cleared');
|
||||
}
|
||||
// Get service info for debugging.
|
||||
getServiceInfo(): object {
|
||||
const baseUrl = options.getOption('minimaxBaseUrl')
|
||||
|| PROVIDER_CONSTANTS.MINIMAX.BASE_URL;
|
||||
const model = options.getOption('minimaxDefaultModel')
|
||||
|| PROVIDER_CONSTANTS.MINIMAX.DEFAULT_MODEL;
|
||||
|
||||
return {
|
||||
provider: 'MiniMax',
|
||||
baseUrl: baseUrl,
|
||||
defaultModel: model,
|
||||
isAvailable: this.isAvailable()
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
@ -6,7 +6,7 @@ import type { ToolCall } from '../tools/tool_interfaces.js';
|
||||
*/
|
||||
export interface ModelMetadata {
|
||||
// The provider that supports this model
|
||||
provider: 'openai' | 'anthropic' | 'ollama' | 'local';
|
||||
provider: 'openai' | 'anthropic' | 'ollama' | 'local' | 'minimax';
|
||||
// The actual model identifier used by the provider's API
|
||||
modelId: string;
|
||||
// Display name for UI (optional)
|
||||
@ -218,3 +218,62 @@ export function createOllamaOptions(
|
||||
providerMetadata: opts.providerMetadata,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* MiniMax-specific options, structured to match the Anthropic-compatible API
|
||||
* MiniMax uses the same API format as Anthropic
|
||||
* Documentation: https://platform.minimax.io/docs/
|
||||
*/
|
||||
export interface MiniMaxOptions extends ProviderConfig {
|
||||
// Connection settings (not sent to API)
|
||||
apiKey: string;
|
||||
baseUrl: string;
|
||||
apiVersion?: string;
|
||||
|
||||
// Direct API parameters as they appear in requests
|
||||
model: string;
|
||||
messages?: any[];
|
||||
system?: string;
|
||||
temperature?: number;
|
||||
max_tokens?: number;
|
||||
stream?: boolean;
|
||||
top_p?: number;
|
||||
|
||||
// Internal parameters (not sent directly to API)
|
||||
formattedMessages?: { messages: any[], system: string };
|
||||
// Streaming callback handler
|
||||
streamCallback?: (text: string, isDone: boolean, originalChunk?: any) => Promise<void> | void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create MiniMax options from generic options and config
|
||||
* MiniMax uses Anthropic-compatible API format
|
||||
*/
|
||||
export function createMiniMaxOptions(
|
||||
opts: ChatCompletionOptions = {},
|
||||
apiKey: string,
|
||||
baseUrl: string,
|
||||
defaultModel: string,
|
||||
apiVersion: string = '2023-06-01'
|
||||
): MiniMaxOptions {
|
||||
return {
|
||||
// Connection settings
|
||||
apiKey,
|
||||
baseUrl,
|
||||
apiVersion,
|
||||
|
||||
// API parameters
|
||||
model: opts.model || defaultModel,
|
||||
temperature: opts.temperature,
|
||||
max_tokens: opts.maxTokens,
|
||||
stream: opts.stream,
|
||||
top_p: opts.topP,
|
||||
|
||||
// Internal configuration
|
||||
systemPrompt: opts.systemPrompt,
|
||||
// Pass through streaming callback
|
||||
streamCallback: opts.streamCallback,
|
||||
// Include provider metadata
|
||||
providerMetadata: opts.providerMetadata,
|
||||
};
|
||||
}
|
||||
|
||||
@ -2,11 +2,12 @@ import options from "../../options.js";
|
||||
import log from "../../log.js";
|
||||
import type { OptionDefinitions } from "@triliumnext/commons";
|
||||
import type { ChatCompletionOptions } from '../ai_interface.js';
|
||||
import type { OpenAIOptions, AnthropicOptions, OllamaOptions, ModelMetadata } from './provider_options.js';
|
||||
import type { OpenAIOptions, AnthropicOptions, OllamaOptions, MiniMaxOptions, ModelMetadata } from './provider_options.js';
|
||||
import {
|
||||
createOpenAIOptions,
|
||||
createAnthropicOptions,
|
||||
createOllamaOptions
|
||||
createOllamaOptions,
|
||||
createMiniMaxOptions
|
||||
} from './provider_options.js';
|
||||
import { PROVIDER_CONSTANTS } from '../constants/provider_constants.js';
|
||||
import { SEARCH_CONSTANTS, MODEL_CAPABILITIES } from '../constants/search_constants.js';
|
||||
@ -262,3 +263,69 @@ async function getOllamaModelContextWindow(modelName: string): Promise<number> {
|
||||
return MODEL_CAPABILITIES['default'].contextWindowTokens; // Default fallback
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MiniMax provider options from chat options and configuration
|
||||
* MiniMax uses Anthropic-compatible API format
|
||||
* Documentation: https://platform.minimax.io/docs/
|
||||
*/
|
||||
export function getMiniMaxOptions(
|
||||
opts: ChatCompletionOptions = {}
|
||||
): MiniMaxOptions {
|
||||
try {
|
||||
const apiKey = options.getOption('minimaxApiKey');
|
||||
|
||||
if (!apiKey) {
|
||||
// Log warning but don't throw - allow checking availability
|
||||
log.info('MiniMax API key is not configured');
|
||||
}
|
||||
|
||||
const baseUrl = options.getOption('minimaxBaseUrl')
|
||||
|| PROVIDER_CONSTANTS.MINIMAX.BASE_URL;
|
||||
|
||||
const modelName = opts.model || options.getOption('minimaxDefaultModel')
|
||||
|| PROVIDER_CONSTANTS.MINIMAX.DEFAULT_MODEL;
|
||||
|
||||
if (!modelName) {
|
||||
throw new Error(
|
||||
'No MiniMax model configured. ' +
|
||||
'Please set a default model in your AI settings.'
|
||||
);
|
||||
}
|
||||
|
||||
// Create provider metadata
|
||||
const providerMetadata: ModelMetadata = {
|
||||
provider: 'minimax',
|
||||
modelId: modelName,
|
||||
displayName: modelName,
|
||||
capabilities: {
|
||||
supportsTools: true,
|
||||
supportsStreaming: true,
|
||||
supportsVision: false,
|
||||
contextWindow: PROVIDER_CONSTANTS.MINIMAX.CONTEXT_WINDOW
|
||||
}
|
||||
};
|
||||
|
||||
// Get temperature from options or global setting
|
||||
const temperature = opts.temperature !== undefined
|
||||
? opts.temperature
|
||||
: parseFloat(options.getOption('aiTemperature') || String(SEARCH_CONSTANTS.TEMPERATURE.DEFAULT));
|
||||
|
||||
// Create options and pass through provider metadata
|
||||
const optionsResult = createMiniMaxOptions(
|
||||
opts,
|
||||
apiKey || '',
|
||||
baseUrl,
|
||||
modelName,
|
||||
PROVIDER_CONSTANTS.MINIMAX.API_VERSION
|
||||
);
|
||||
|
||||
// Pass through provider metadata
|
||||
optionsResult.providerMetadata = providerMetadata;
|
||||
|
||||
return optionsResult;
|
||||
} catch (error) {
|
||||
log.error(`Error creating MiniMax provider options: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
52
apps/server/src/services/llm/response_normalizer.ts
Normal file
52
apps/server/src/services/llm/response_normalizer.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import type { ChatResponse, NormalizedChatResponse } from './ai_interface.js';
|
||||
import type { ToolCall } from './tools/tool_interfaces.js';
|
||||
|
||||
interface NormalizedToolCall {
|
||||
id: string;
|
||||
type?: string;
|
||||
function: {
|
||||
name: string;
|
||||
arguments: Record<string, unknown> | string;
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeToolCall(toolCall: ToolCall, index: number): NormalizedToolCall | null {
|
||||
if (!toolCall || !toolCall.function || typeof toolCall.function.name !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const name = toolCall.function.name.trim();
|
||||
if (!name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawArgs = toolCall.function.arguments;
|
||||
const normalizedArgs = rawArgs === undefined || rawArgs === null ? {} : rawArgs;
|
||||
|
||||
return {
|
||||
id: toolCall.id || `call_${index}`,
|
||||
type: toolCall.type,
|
||||
function: {
|
||||
name,
|
||||
arguments: normalizedArgs
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeChatResponse(response: ChatResponse): NormalizedChatResponse {
|
||||
const toolCalls = Array.isArray(response.tool_calls)
|
||||
? response.tool_calls
|
||||
: [];
|
||||
|
||||
const normalizedToolCalls = toolCalls
|
||||
.map((toolCall, index) => normalizeToolCall(toolCall, index))
|
||||
.filter((toolCall): toolCall is NormalizedToolCall => toolCall !== null);
|
||||
|
||||
const normalizedText = typeof response.text === 'string' ? response.text : '';
|
||||
|
||||
return {
|
||||
...response,
|
||||
text: normalizedText,
|
||||
tool_calls: normalizedToolCalls
|
||||
};
|
||||
}
|
||||
107
apps/server/src/services/llm/tools/list_notes_tool.ts
Normal file
107
apps/server/src/services/llm/tools/list_notes_tool.ts
Normal file
@ -0,0 +1,107 @@
|
||||
/**
|
||||
* List Notes Tool
|
||||
*
|
||||
* This tool lists child notes under a parent note, useful for
|
||||
* "what notes do I have" style questions.
|
||||
*/
|
||||
|
||||
import type { Tool, ToolHandler } from './tool_interfaces.js';
|
||||
import log from '../../log.js';
|
||||
import becca from '../../../becca/becca.js';
|
||||
import BNote from '../../../becca/entities/bnote.js';
|
||||
|
||||
const DEFAULT_MAX_RESULTS = 50;
|
||||
|
||||
export const listNotesToolDefinition: Tool = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'list_notes',
|
||||
description: 'List child notes under a parent note. Use this to show what notes exist at the top level or within a specific branch.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
parentNoteId: {
|
||||
type: 'string',
|
||||
description: 'System ID of the parent note to list children from. Defaults to the root note.'
|
||||
},
|
||||
maxResults: {
|
||||
type: 'number',
|
||||
description: `Maximum number of child notes to return (default: ${DEFAULT_MAX_RESULTS})`
|
||||
},
|
||||
includeArchived: {
|
||||
type: 'boolean',
|
||||
description: 'Whether to include archived notes (default: false)'
|
||||
},
|
||||
includeHidden: {
|
||||
type: 'boolean',
|
||||
description: 'Whether to include hidden notes (default: false)'
|
||||
}
|
||||
},
|
||||
required: []
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export class ListNotesTool implements ToolHandler {
|
||||
public definition: Tool = listNotesToolDefinition;
|
||||
|
||||
public async execute(args: {
|
||||
parentNoteId?: string;
|
||||
maxResults?: number;
|
||||
includeArchived?: boolean;
|
||||
includeHidden?: boolean;
|
||||
}): Promise<string | object> {
|
||||
try {
|
||||
const {
|
||||
parentNoteId,
|
||||
maxResults = DEFAULT_MAX_RESULTS,
|
||||
includeArchived = false,
|
||||
includeHidden = false
|
||||
} = args;
|
||||
|
||||
const parent: BNote | null = parentNoteId
|
||||
? (becca.notes[parentNoteId] ?? null)
|
||||
: becca.getNote('root');
|
||||
|
||||
if (!parent) {
|
||||
return `Error: Parent note with ID ${parentNoteId} not found. Please specify a valid parent note ID.`;
|
||||
}
|
||||
|
||||
const children = parent.getChildNotes();
|
||||
const filtered = children.filter((note) => {
|
||||
if (!includeArchived && note.isArchived) {
|
||||
return false;
|
||||
}
|
||||
if (!includeHidden && note.isHiddenCompletely()) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const limited = filtered.slice(0, Math.max(0, maxResults));
|
||||
const results = limited.map((note) => ({
|
||||
noteId: note.noteId,
|
||||
title: note.getTitleOrProtected(),
|
||||
type: note.type,
|
||||
mime: note.mime,
|
||||
isArchived: note.isArchived,
|
||||
isHidden: note.isHiddenCompletely(),
|
||||
childCount: note.getChildNotes().length,
|
||||
path: note.getBestNotePathString()
|
||||
}));
|
||||
|
||||
log.info(`Listed ${results.length}/${filtered.length} notes under ${parent.noteId}`);
|
||||
|
||||
return {
|
||||
parentNoteId: parent.noteId,
|
||||
parentTitle: parent.getTitleOrProtected(),
|
||||
count: results.length,
|
||||
totalFound: filtered.length,
|
||||
results
|
||||
};
|
||||
} catch (error: any) {
|
||||
log.error(`Error executing list_notes tool: ${error.message || String(error)}`);
|
||||
return `Error: ${error.message || String(error)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
154
apps/server/src/services/llm/tools/move_note_tool.ts
Normal file
154
apps/server/src/services/llm/tools/move_note_tool.ts
Normal file
@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Move Note Tool
|
||||
*
|
||||
* This tool moves a note under a different parent note.
|
||||
* It operates on branches to preserve Trilium's multi-parent model.
|
||||
*/
|
||||
|
||||
import type { Tool, ToolHandler } from './tool_interfaces.js';
|
||||
import log from '../../log.js';
|
||||
import becca from '../../../becca/becca.js';
|
||||
import branchService from '../../branches.js';
|
||||
import type BBranch from '../../../becca/entities/bbranch.js';
|
||||
|
||||
export const moveNoteToolDefinition: Tool = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'move_note',
|
||||
description: 'Move a note under a different parent note. If the note has multiple parents, provide sourceParentNoteId or branchId to choose which branch to move.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
noteId: {
|
||||
type: 'string',
|
||||
description: 'System ID of the note to move (not the title).'
|
||||
},
|
||||
targetParentNoteId: {
|
||||
type: 'string',
|
||||
description: 'System ID of the destination parent note.'
|
||||
},
|
||||
targetParentBranchId: {
|
||||
type: 'string',
|
||||
description: 'Optional branch ID of the destination parent. Prefer this when the parent note has multiple parents.'
|
||||
},
|
||||
sourceParentNoteId: {
|
||||
type: 'string',
|
||||
description: 'Optional system ID of the current parent note. Required if the note has multiple parents.'
|
||||
},
|
||||
branchId: {
|
||||
type: 'string',
|
||||
description: 'Optional branch ID to move. Use this if you know the exact branch to move.'
|
||||
}
|
||||
},
|
||||
required: ['noteId', 'targetParentNoteId']
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export class MoveNoteTool implements ToolHandler {
|
||||
public definition: Tool = moveNoteToolDefinition;
|
||||
|
||||
public async execute(args: {
|
||||
noteId: string;
|
||||
targetParentNoteId: string;
|
||||
targetParentBranchId?: string;
|
||||
sourceParentNoteId?: string;
|
||||
branchId?: string;
|
||||
}): Promise<string | object> {
|
||||
try {
|
||||
const { noteId, targetParentNoteId, targetParentBranchId, sourceParentNoteId, branchId } = args;
|
||||
|
||||
const note = becca.notes[noteId];
|
||||
if (!note) {
|
||||
return `Error: Note with ID ${noteId} not found.`;
|
||||
}
|
||||
|
||||
const targetParent = becca.notes[targetParentNoteId];
|
||||
if (!targetParent) {
|
||||
return `Error: Target parent note with ID ${targetParentNoteId} not found.`;
|
||||
}
|
||||
|
||||
const branchToMove = this.resolveBranch(noteId, sourceParentNoteId, branchId);
|
||||
if (!branchToMove) {
|
||||
const parentBranchIds = note.getParentBranches().map(parentBranch => parentBranch.parentNoteId);
|
||||
return `Error: Unable to resolve the note branch to move. Provide sourceParentNoteId or branchId when the note has multiple parents. Available parents: ${parentBranchIds.join(', ') || 'none'}.`;
|
||||
}
|
||||
|
||||
const fromParentNoteId = branchToMove.parentNoteId;
|
||||
log.info(`Executing move_note tool - NoteID: ${noteId}, from: ${fromParentNoteId}, to: ${targetParentNoteId}`);
|
||||
|
||||
let result;
|
||||
if (targetParentBranchId) {
|
||||
const targetParentBranch = becca.getBranch(targetParentBranchId);
|
||||
if (!targetParentBranch) {
|
||||
return `Error: Target parent branch with ID ${targetParentBranchId} not found.`;
|
||||
}
|
||||
if (targetParentBranch.noteId !== targetParentNoteId) {
|
||||
return `Error: Target parent branch ${targetParentBranchId} does not belong to note ${targetParentNoteId}.`;
|
||||
}
|
||||
result = branchService.moveBranchToBranch(branchToMove, targetParentBranch, branchToMove.branchId || '');
|
||||
} else {
|
||||
result = branchService.moveBranchToNote(branchToMove, targetParentNoteId);
|
||||
}
|
||||
const rawResult = Array.isArray(result) ? result[1] : result;
|
||||
|
||||
// Type guard for success result
|
||||
const isSuccessResult = (r: unknown): r is { success: boolean; branch?: BBranch; message?: string } =>
|
||||
typeof r === 'object' && r !== null && 'success' in r;
|
||||
|
||||
if (!isSuccessResult(rawResult) || !rawResult.success) {
|
||||
const message = isSuccessResult(rawResult) && 'message' in rawResult
|
||||
? String(rawResult.message)
|
||||
: 'Move failed due to validation or unknown error.';
|
||||
return `Error: ${message}`;
|
||||
}
|
||||
|
||||
const newBranchId = rawResult.branch?.branchId;
|
||||
let cleanupRemovedSourceBranch = false;
|
||||
if (fromParentNoteId !== targetParentNoteId) {
|
||||
const remainingBranch = becca.getBranchFromChildAndParent(noteId, fromParentNoteId);
|
||||
if (remainingBranch && remainingBranch.branchId !== newBranchId) {
|
||||
remainingBranch.markAsDeleted();
|
||||
cleanupRemovedSourceBranch = true;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
noteId: note.noteId,
|
||||
title: note.title,
|
||||
fromParentNoteId,
|
||||
toParentNoteId: targetParentNoteId,
|
||||
branchId: newBranchId,
|
||||
cleanupRemovedSourceBranch,
|
||||
message: `Moved "${note.title}" to new parent ${targetParentNoteId}`
|
||||
};
|
||||
} catch (error: any) {
|
||||
log.error(`Error executing move_note tool: ${error.message || String(error)}`);
|
||||
return `Error: ${error.message || String(error)}`;
|
||||
}
|
||||
}
|
||||
|
||||
private resolveBranch(noteId: string, sourceParentNoteId?: string, branchId?: string): BBranch | null {
|
||||
if (branchId) {
|
||||
const byId = becca.getBranch(branchId);
|
||||
return byId && byId.noteId === noteId ? byId : null;
|
||||
}
|
||||
|
||||
if (sourceParentNoteId) {
|
||||
return becca.getBranchFromChildAndParent(noteId, sourceParentNoteId);
|
||||
}
|
||||
|
||||
const note = becca.notes[noteId];
|
||||
if (!note) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parentBranches = note.getParentBranches();
|
||||
if (parentBranches.length === 1) {
|
||||
return parentBranches[0];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
37
apps/server/src/services/llm/tools/note_content_utils.ts
Normal file
37
apps/server/src/services/llm/tools/note_content_utils.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import markdownService from '../../import/markdown.js';
|
||||
|
||||
const HTML_TAG_PATTERN = /<\/?[a-z][\s\S]*>/i;
|
||||
const MARKDOWN_PATTERNS: RegExp[] = [
|
||||
/^#{1,6}\s+/m,
|
||||
/^\s*[-*+]\s+/m,
|
||||
/^\s*\d+\.\s+/m,
|
||||
/```[\s\S]*```/m,
|
||||
/\[[^\]]+\]\([^)]+\)/m,
|
||||
/^\s*>\s+/m
|
||||
];
|
||||
|
||||
export function normalizeTextNoteContent(
|
||||
content: string,
|
||||
title: string,
|
||||
noteType: string,
|
||||
noteMime: string
|
||||
): { content: string; converted: boolean } {
|
||||
if (noteType !== 'text' || noteMime !== 'text/html') {
|
||||
return { content, converted: false };
|
||||
}
|
||||
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed || HTML_TAG_PATTERN.test(trimmed)) {
|
||||
return { content, converted: false };
|
||||
}
|
||||
|
||||
const looksLikeMarkdown = MARKDOWN_PATTERNS.some((pattern) => pattern.test(trimmed));
|
||||
if (!looksLikeMarkdown) {
|
||||
return { content, converted: false };
|
||||
}
|
||||
|
||||
return {
|
||||
content: markdownService.renderToHtml(content, title),
|
||||
converted: true
|
||||
};
|
||||
}
|
||||
@ -10,6 +10,8 @@ import becca from '../../../becca/becca.js';
|
||||
import notes from '../../notes.js';
|
||||
import attributes from '../../attributes.js';
|
||||
import BNote from '../../../becca/entities/bnote.js';
|
||||
import { normalizeTextNoteContent } from './note_content_utils.js';
|
||||
import { NOTE_WRITE_RULES } from './note_tool_prompt_rules.js';
|
||||
|
||||
/**
|
||||
* Definition of the note creation tool
|
||||
@ -18,7 +20,9 @@ export const noteCreationToolDefinition: Tool = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'create_note',
|
||||
description: 'Create a new note in Trilium with the specified content and attributes',
|
||||
description: `Create a new note in Trilium with the specified content and attributes.
|
||||
|
||||
${NOTE_WRITE_RULES}`,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@ -32,7 +36,7 @@ export const noteCreationToolDefinition: Tool = {
|
||||
},
|
||||
content: {
|
||||
type: 'string',
|
||||
description: 'Content of the new note'
|
||||
description: 'Content of the new note. Must match the note type rules in the tool description.'
|
||||
},
|
||||
type: {
|
||||
type: 'string',
|
||||
@ -128,12 +132,18 @@ export class NoteCreationTool implements ToolHandler {
|
||||
}
|
||||
}
|
||||
|
||||
const normalized = normalizeTextNoteContent(content, title, type, noteMime);
|
||||
const noteContent = normalized.content;
|
||||
if (normalized.converted) {
|
||||
log.info(`Converted markdown content to HTML for note "${title}"`);
|
||||
}
|
||||
|
||||
// Create the note
|
||||
const createStartTime = Date.now();
|
||||
const result = notes.createNewNote({
|
||||
parentNoteId: parent.noteId,
|
||||
title: title,
|
||||
content: content,
|
||||
content: noteContent,
|
||||
type: type as any, // Cast as any since not all string values may match the exact NoteType union
|
||||
mime: noteMime
|
||||
});
|
||||
|
||||
17
apps/server/src/services/llm/tools/note_tool_prompt_rules.ts
Normal file
17
apps/server/src/services/llm/tools/note_tool_prompt_rules.ts
Normal file
@ -0,0 +1,17 @@
|
||||
export const NOTE_WRITE_RULES = `MUST follow Trilium note rules:
|
||||
- Text note (type "text", mime "text/html"): content MUST be HTML (no Markdown). Allowed tags: p, br, h1-h5, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, strong, em, code, pre, kbd, sup, sub, hr, img, a.
|
||||
- Internal links (create backlinks): use <a class="reference-link" href="#root/<notePath>">Title</a>. Use noteId/notePath; do not use external URLs for internal notes.
|
||||
- Code note (type "code"): content is plain text. Set mime to match language (text/plain, application/json, text/javascript, text/css, etc.).
|
||||
- Mermaid note (type "mermaid", mime "text/mermaid"): content is Mermaid syntax only.
|
||||
- Canvas/Mind Map/Relation Map (types "canvas"/"mindMap"/"relationMap"): content is JSON; only create/update if the user provides or explicitly requests the JSON format.
|
||||
- Render note (type "render"): content is empty; use a relation attribute named renderNote pointing to an HTML/JSX code note to render.
|
||||
- Saved Search (type "search"): content is a search query, not HTML.
|
||||
- Web View (type "webView"): set label #webViewSrc with the URL; do not embed HTML.
|
||||
- Reserved types (file/image/doc/aiChat/contentWidget/launcher) must not be created via tools; use import/attachment workflows instead.
|
||||
- There is no dedicated "folder" type; any note can have children.`;
|
||||
|
||||
export const NOTE_READ_RULES = `When reading notes:
|
||||
- Text note content is HTML (not Markdown). Preserve HTML when quoting or summarizing.
|
||||
- Internal links use <a class="reference-link" href="#root/<notePath>">; backlinks are automatic.
|
||||
- Non-text types may return JSON or binary content (canvas/mindMap/relationMap/mermaid/file/image); do not interpret them as HTML.
|
||||
- If you need render/web view/search behavior, request attributes (renderNote, webViewSrc, searchHome) via includeAttributes.`;
|
||||
@ -8,6 +8,8 @@ import type { Tool, ToolHandler } from './tool_interfaces.js';
|
||||
import log from '../../log.js';
|
||||
import becca from '../../../becca/becca.js';
|
||||
import notes from '../../notes.js';
|
||||
import { normalizeTextNoteContent } from './note_content_utils.js';
|
||||
import { NOTE_WRITE_RULES } from './note_tool_prompt_rules.js';
|
||||
|
||||
/**
|
||||
* Definition of the note update tool
|
||||
@ -16,7 +18,9 @@ export const noteUpdateToolDefinition: Tool = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'update_note',
|
||||
description: 'Update the content or title of an existing note',
|
||||
description: `Update the content or title of an existing note.
|
||||
|
||||
${NOTE_WRITE_RULES}`,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@ -30,7 +34,7 @@ export const noteUpdateToolDefinition: Tool = {
|
||||
},
|
||||
content: {
|
||||
type: 'string',
|
||||
description: 'New content for the note (if you want to change it)'
|
||||
description: 'New content for the note (if you want to change it). Must match the note type rules in the tool description.'
|
||||
},
|
||||
mode: {
|
||||
type: 'string',
|
||||
@ -98,17 +102,26 @@ export class NoteUpdateTool implements ToolHandler {
|
||||
const contentStartTime = Date.now();
|
||||
|
||||
try {
|
||||
let newContent = content;
|
||||
const targetTitle = title || note.title;
|
||||
const normalized = normalizeTextNoteContent(content, targetTitle, note.type, note.mime);
|
||||
let newContent = normalized.content;
|
||||
|
||||
if (normalized.converted) {
|
||||
log.info(`Converted markdown content to HTML for note "${targetTitle}"`);
|
||||
}
|
||||
|
||||
// For append or prepend modes, get the current content first
|
||||
if (mode === 'append' || mode === 'prepend') {
|
||||
const currentContent = await note.getContent();
|
||||
const currentContentText = typeof currentContent === 'string'
|
||||
? currentContent
|
||||
: currentContent.toString();
|
||||
|
||||
if (mode === 'append') {
|
||||
newContent = currentContent + '\n\n' + content;
|
||||
newContent = currentContentText + '\n\n' + newContent;
|
||||
log.info(`Appending content to existing note content`);
|
||||
} else if (mode === 'prepend') {
|
||||
newContent = content + '\n\n' + currentContent;
|
||||
newContent = newContent + '\n\n' + currentContentText;
|
||||
log.info(`Prepending content to existing note content`);
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
import type { Tool, ToolHandler } from './tool_interfaces.js';
|
||||
import log from '../../log.js';
|
||||
import becca from '../../../becca/becca.js';
|
||||
import { NOTE_READ_RULES } from './note_tool_prompt_rules.js';
|
||||
|
||||
// Define type for note response
|
||||
interface NoteResponse {
|
||||
@ -34,7 +35,9 @@ export const readNoteToolDefinition: Tool = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'read_note',
|
||||
description: 'Read the content of a specific note by its ID',
|
||||
description: `Read the content of a specific note by its ID.
|
||||
|
||||
${NOTE_READ_RULES}`,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@ -44,7 +47,7 @@ export const readNoteToolDefinition: Tool = {
|
||||
},
|
||||
includeAttributes: {
|
||||
type: 'boolean',
|
||||
description: 'Whether to include note attributes in the response (default: false)'
|
||||
description: 'Whether to include note attributes in the response (default: false). Use this for render/web/search labels and relations.'
|
||||
}
|
||||
},
|
||||
required: ['noteId']
|
||||
|
||||
@ -9,6 +9,7 @@ import log from '../../log.js';
|
||||
import aiServiceManager from '../ai_service_manager.js';
|
||||
import becca from '../../../becca/becca.js';
|
||||
import { ContextExtractor } from '../context/index.js';
|
||||
import searchService from '../../search/services/search.js';
|
||||
|
||||
/**
|
||||
* Definition of the search notes tool
|
||||
@ -189,6 +190,58 @@ export class SearchNotesTool implements ToolHandler {
|
||||
}
|
||||
}
|
||||
|
||||
private async executeKeywordFallback(args: {
|
||||
query: string;
|
||||
parentNoteId?: string;
|
||||
maxResults: number;
|
||||
summarize: boolean;
|
||||
}): Promise<string | object> {
|
||||
const { query, parentNoteId, maxResults, summarize } = args;
|
||||
log.info(`Vector search unavailable, using keyword fallback for query: "${query}"`);
|
||||
|
||||
const searchContext = {
|
||||
includeArchivedNotes: false,
|
||||
includeHiddenNotes: false,
|
||||
ancestorNoteId: parentNoteId,
|
||||
fuzzyAttributeSearch: false
|
||||
};
|
||||
|
||||
const searchResults = searchService.searchNotes(query, searchContext);
|
||||
const limitedResults = searchResults.slice(0, maxResults);
|
||||
|
||||
const enhancedResults = await Promise.all(
|
||||
limitedResults.map(async note => {
|
||||
const preview = await this.getRichContentPreview(note.noteId, summarize);
|
||||
return {
|
||||
noteId: note.noteId,
|
||||
title: note.title,
|
||||
preview,
|
||||
dateCreated: note.dateCreated,
|
||||
dateModified: note.dateModified,
|
||||
type: note.type,
|
||||
mime: note.mime,
|
||||
matchType: 'keyword'
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
if (enhancedResults.length === 0) {
|
||||
return {
|
||||
count: 0,
|
||||
results: [],
|
||||
query,
|
||||
message: 'No notes found matching your query. Try broader terms or different keywords. Note: Use the noteId (not the title) when performing operations on specific notes with other tools.'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
count: enhancedResults.length,
|
||||
totalFound: searchResults.length,
|
||||
results: enhancedResults,
|
||||
message: 'Vector search unavailable. Results are from keyword search; use noteId for follow-up operations.'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the search notes tool
|
||||
*/
|
||||
@ -212,7 +265,7 @@ export class SearchNotesTool implements ToolHandler {
|
||||
const vectorSearchTool = await getOrCreateVectorSearchTool();
|
||||
|
||||
if (!vectorSearchTool) {
|
||||
return `Error: Vector search tool is not available. The system may still be initializing or there could be a configuration issue.`;
|
||||
return await this.executeKeywordFallback({ query, parentNoteId, maxResults, summarize });
|
||||
}
|
||||
|
||||
log.info(`Retrieved vector search tool from AI service manager`);
|
||||
@ -220,13 +273,19 @@ export class SearchNotesTool implements ToolHandler {
|
||||
// Check if searchNotes method exists
|
||||
if (!vectorSearchTool.searchNotes || typeof vectorSearchTool.searchNotes !== 'function') {
|
||||
log.error(`Vector search tool is missing searchNotes method`);
|
||||
return `Error: Vector search tool is improperly configured (missing searchNotes method).`;
|
||||
return await this.executeKeywordFallback({ query, parentNoteId, maxResults, summarize });
|
||||
}
|
||||
|
||||
// Execute the search
|
||||
log.info(`Performing semantic search for: "${query}"`);
|
||||
const searchStartTime = Date.now();
|
||||
const response = await vectorSearchTool.searchNotes(query, parentNoteId, maxResults);
|
||||
let response;
|
||||
try {
|
||||
response = await vectorSearchTool.searchNotes(query, parentNoteId, maxResults);
|
||||
} catch (error: any) {
|
||||
log.error(`Semantic search failed, falling back to keyword search: ${error.message || String(error)}`);
|
||||
return await this.executeKeywordFallback({ query, parentNoteId, maxResults, summarize });
|
||||
}
|
||||
const results: Array<Record<string, unknown>> = response?.matches ?? [];
|
||||
const searchDuration = Date.now() - searchStartTime;
|
||||
|
||||
|
||||
49
apps/server/src/services/llm/tools/tool_argument_parser.ts
Normal file
49
apps/server/src/services/llm/tools/tool_argument_parser.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import type { ParsedToolArguments } from './tool_interfaces.js';
|
||||
|
||||
function sanitizeJsonArgument(value: string): string {
|
||||
return value
|
||||
.replace(/^['"]/g, '')
|
||||
.replace(/['"]$/g, '')
|
||||
.replace(/\\"/g, '"')
|
||||
.replace(/([{,])\s*'([^']+)'\s*:/g, '$1"$2":')
|
||||
.replace(/([{,])\s*(\w+)\s*:/g, '$1"$2":');
|
||||
}
|
||||
|
||||
export function parseToolArguments(input: string | Record<string, unknown>): ParsedToolArguments {
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (typeof input === 'object' && input !== null) {
|
||||
return { args: input, warnings };
|
||||
}
|
||||
|
||||
if (typeof input !== 'string') {
|
||||
warnings.push('Tool arguments were not a string or object; defaulting to empty object.');
|
||||
return { args: {}, warnings };
|
||||
}
|
||||
|
||||
if (input.trim() === '') {
|
||||
warnings.push('Tool arguments were an empty string; defaulting to empty object.');
|
||||
return { args: {}, warnings };
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(input) as Record<string, unknown>;
|
||||
return { args: parsed, warnings };
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
warnings.push(`Failed to parse arguments as JSON: ${message}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const cleaned = sanitizeJsonArgument(input);
|
||||
const parsed = JSON.parse(cleaned) as Record<string, unknown>;
|
||||
warnings.push('Parsed arguments after sanitizing malformed JSON.');
|
||||
return { args: parsed, warnings };
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
warnings.push(`Failed to parse sanitized arguments: ${message}`);
|
||||
}
|
||||
|
||||
warnings.push('Falling back to text argument payload.');
|
||||
return { args: { text: input }, warnings };
|
||||
}
|
||||
@ -10,8 +10,10 @@ import { KeywordSearchTool } from './keyword_search_tool.js';
|
||||
import { AttributeSearchTool } from './attribute_search_tool.js';
|
||||
import { SearchSuggestionTool } from './search_suggestion_tool.js';
|
||||
import { ReadNoteTool } from './read_note_tool.js';
|
||||
import { ListNotesTool } from './list_notes_tool.js';
|
||||
import { NoteCreationTool } from './note_creation_tool.js';
|
||||
import { NoteUpdateTool } from './note_update_tool.js';
|
||||
import { MoveNoteTool } from './move_note_tool.js';
|
||||
import { ContentExtractionTool } from './content_extraction_tool.js';
|
||||
import { RelationshipTool } from './relationship_tool.js';
|
||||
import { AttributeManagerTool } from './attribute_manager_tool.js';
|
||||
@ -38,10 +40,12 @@ export async function initializeTools(): Promise<void> {
|
||||
toolRegistry.registerTool(new AttributeSearchTool()); // Attribute-specific search
|
||||
toolRegistry.registerTool(new SearchSuggestionTool()); // Search syntax helper
|
||||
toolRegistry.registerTool(new ReadNoteTool()); // Read note content
|
||||
toolRegistry.registerTool(new ListNotesTool()); // List child notes
|
||||
|
||||
// Register note creation and manipulation tools
|
||||
toolRegistry.registerTool(new NoteCreationTool()); // Create new notes
|
||||
toolRegistry.registerTool(new NoteUpdateTool()); // Update existing notes
|
||||
toolRegistry.registerTool(new MoveNoteTool()); // Move notes in the tree
|
||||
toolRegistry.registerTool(new NoteSummarizationTool()); // Summarize note content
|
||||
|
||||
// Register attribute and relationship tools
|
||||
|
||||
@ -53,6 +53,27 @@ export interface ToolCall {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsed tool arguments and any warnings captured during parsing.
|
||||
*/
|
||||
export interface ParsedToolArguments {
|
||||
args: Record<string, unknown>;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Argument parser function for tool calls.
|
||||
*/
|
||||
export type ToolArgumentParser = (input: string | Record<string, unknown>) => ParsedToolArguments;
|
||||
|
||||
/**
|
||||
* Metadata for tool execution helpers.
|
||||
*/
|
||||
export interface ToolMetadata {
|
||||
name: string;
|
||||
parseArguments: ToolArgumentParser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for a tool handler that executes a tool
|
||||
*/
|
||||
@ -66,4 +87,9 @@ export interface ToolHandler {
|
||||
* Execute the tool with the given arguments
|
||||
*/
|
||||
execute(args: Record<string, unknown>): Promise<string | object>;
|
||||
|
||||
/**
|
||||
* Optional argument parser override for this tool.
|
||||
*/
|
||||
parseArguments?: ToolArgumentParser;
|
||||
}
|
||||
|
||||
@ -4,7 +4,8 @@
|
||||
* This file defines the registry for tools that can be called by LLMs.
|
||||
*/
|
||||
|
||||
import type { Tool, ToolHandler } from './tool_interfaces.js';
|
||||
import type { Tool, ToolHandler, ToolMetadata } from './tool_interfaces.js';
|
||||
import { parseToolArguments } from './tool_argument_parser.js';
|
||||
import log from '../../log.js';
|
||||
|
||||
/**
|
||||
@ -13,6 +14,7 @@ import log from '../../log.js';
|
||||
export class ToolRegistry {
|
||||
private static instance: ToolRegistry;
|
||||
private tools: Map<string, ToolHandler> = new Map();
|
||||
private metadata: Map<string, ToolMetadata> = new Map();
|
||||
private initializationAttempted = false;
|
||||
|
||||
private constructor() {}
|
||||
@ -100,12 +102,14 @@ export class ToolRegistry {
|
||||
}
|
||||
|
||||
const name = handler.definition.function.name;
|
||||
const parseArguments = handler.parseArguments || parseToolArguments;
|
||||
|
||||
if (this.tools.has(name)) {
|
||||
log.info(`Tool '${name}' already registered, replacing...`);
|
||||
}
|
||||
|
||||
this.tools.set(name, handler);
|
||||
this.metadata.set(name, { name, parseArguments });
|
||||
}
|
||||
|
||||
/**
|
||||
@ -146,6 +150,13 @@ export class ToolRegistry {
|
||||
return Array.from(this.tools.values()).filter(tool => this.validateToolHandler(tool));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tool metadata by name
|
||||
*/
|
||||
public getToolMetadata(name: string): ToolMetadata | undefined {
|
||||
return this.metadata.get(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tool definitions for sending to LLM
|
||||
*/
|
||||
|
||||
@ -87,7 +87,8 @@ function setOption<T extends OptionNames>(name: T, value: string | OptionDefinit
|
||||
const aiOptions = [
|
||||
'aiSelectedProvider', 'openaiApiKey', 'openaiBaseUrl', 'openaiDefaultModel',
|
||||
'anthropicApiKey', 'anthropicBaseUrl', 'anthropicDefaultModel',
|
||||
'ollamaBaseUrl', 'ollamaDefaultModel'
|
||||
'ollamaBaseUrl', 'ollamaDefaultModel',
|
||||
'minimaxApiKey', 'minimaxBaseUrl', 'minimaxDefaultModel'
|
||||
];
|
||||
|
||||
if (aiOptions.includes(name)) {
|
||||
|
||||
@ -214,6 +214,9 @@ const defaultOptions: DefaultOption[] = [
|
||||
{ name: "ollamaEnabled", value: "false", isSynced: true },
|
||||
{ name: "ollamaDefaultModel", value: "", isSynced: true },
|
||||
{ name: "ollamaBaseUrl", value: "http://localhost:11434", isSynced: true },
|
||||
{ name: "minimaxApiKey", value: "", isSynced: false },
|
||||
{ name: "minimaxDefaultModel", value: "", isSynced: true },
|
||||
{ name: "minimaxBaseUrl", value: "https://api.minimax.io/anthropic", isSynced: true },
|
||||
{ name: "aiTemperature", value: "0.7", isSynced: true },
|
||||
{ name: "aiSystemPrompt", value: "", isSynced: true },
|
||||
{ name: "aiSelectedProvider", value: "openai", isSynced: true },
|
||||
|
||||
@ -154,6 +154,9 @@ export interface OptionDefinitions extends KeyboardShortcutsOptions<KeyboardActi
|
||||
ollamaEnabled: boolean;
|
||||
ollamaBaseUrl: string;
|
||||
ollamaDefaultModel: string;
|
||||
minimaxApiKey: string;
|
||||
minimaxDefaultModel: string;
|
||||
minimaxBaseUrl: string;
|
||||
codeOpenAiModel: string;
|
||||
aiSelectedProvider: string;
|
||||
seenCallToActions: string;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user