Compare commits

...

94 Commits

Author SHA1 Message Date
dependabot[bot]
f7e167d2ec chore(deps): bump the npm_and_yarn group across 1 directory with 4 updates
Bumps the npm_and_yarn group with 3 updates in the /docs directory: [next](https://github.com/vercel/next.js), [dompurify](https://github.com/cure53/DOMPurify) and [mermaid](https://github.com/mermaid-js/mermaid).


Updates `next` from 15.3.3 to 15.5.6
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v15.3.3...v15.5.6)

Updates `dompurify` from 3.1.6 to 3.3.0
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.1.6...3.3.0)

Updates `mermaid` from 10.9.3 to 10.9.5
- [Release notes](https://github.com/mermaid-js/mermaid/releases)
- [Commits](https://github.com/mermaid-js/mermaid/compare/v10.9.3...v10.9.5)

Updates `mermaid` from 10.9.3 to 11.12.1
- [Release notes](https://github.com/mermaid-js/mermaid/releases)
- [Commits](https://github.com/mermaid-js/mermaid/compare/v10.9.3...v10.9.5)

Updates `micromatch` from 4.0.5 to 4.0.8
- [Release notes](https://github.com/micromatch/micromatch/releases)
- [Changelog](https://github.com/micromatch/micromatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/micromatch/compare/4.0.5...4.0.8)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 15.5.6
  dependency-type: direct:production
  dependency-group: npm_and_yarn
- dependency-name: dompurify
  dependency-version: 3.3.0
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: mermaid
  dependency-version: 10.9.5
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: mermaid
  dependency-version: 11.12.1
  dependency-type: indirect
  dependency-group: npm_and_yarn
- dependency-name: micromatch
  dependency-version: 4.0.8
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-14 19:17:13 +00:00
Alex
9e58eb02b3 Update .env.development 2025-11-14 19:53:19 +02:00
Siddhant Rai
3f7de867cc feat: model registry and capabilities for multi-provider support (#2158)
* feat: Implement model registry and capabilities for multi-provider support

- Added ModelRegistry to manage available models and their capabilities.
- Introduced ModelProvider enum for different LLM providers.
- Created ModelCapabilities dataclass to define model features.
- Implemented methods to load models based on API keys and settings.
- Added utility functions for model management in model_utils.py.
- Updated settings.py to include provider-specific API keys.
- Refactored LLM classes (Anthropic, OpenAI, Google, etc.) to utilize new model registry.
- Enhanced utility functions to handle token limits and model validation.
- Improved code structure and logging for better maintainability.

* feat: Add model selection feature with API integration and UI component

* feat: Add model selection and default model functionality in agent management

* test: Update assertions and formatting in stream processing tests

* refactor(llm): Standardize model identifier to model_id

* fix tests

---------

Co-authored-by: Alex <a@tushynski.me>
2025-11-14 13:13:19 +02:00
Manish Madan
fbf7cf874b chore(dependabot): add react-widget npm dependency updates (#2146) 2025-11-07 17:17:46 +02:00
Manish Madan
ba7278b80f Merge pull request #2140 from arc53/dependabot/npm_and_yarn/frontend/husky-9.1.7
chore(deps-dev): bump husky from 8.0.3 to 9.1.7 in /frontend
2025-11-07 03:02:52 +05:30
ManishMadan2882
9d649de6f9 chore(eslint): migrate to ESLint 9 flat config format
- Add eslint.config.js with ESLint 9 flat config format
- Remove deprecated .eslintrc.cjs file
- Remove deprecated .eslintignore file (replaced by ignores in config)
- Maintain all existing ESLint rules and configurations
- Ensure compatibility with Husky 9.1.7
2025-11-07 02:59:51 +05:30
dependabot[bot]
7929afbf58 chore(deps-dev): bump husky from 8.0.3 to 9.1.7 in /frontend
Bumps [husky](https://github.com/typicode/husky) from 8.0.3 to 9.1.7.
- [Release notes](https://github.com/typicode/husky/releases)
- [Commits](https://github.com/typicode/husky/compare/v8.0.3...v9.1.7)

---
updated-dependencies:
- dependency-name: husky
  dependency-version: 9.1.7
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-06 21:27:39 +00:00
Manish Madan
ceaf942e70 Merge pull request #2139 from arc53/dependabot/npm_and_yarn/frontend/eslint-9.39.1
chore(deps-dev): bump eslint from 8.57.1 to 9.39.1 in /frontend
2025-11-07 02:33:32 +05:30
dependabot[bot]
f355601a44 chore(deps-dev): bump eslint from 8.57.1 to 9.39.1 in /frontend
Bumps [eslint](https://github.com/eslint/eslint) from 8.57.1 to 9.39.1.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v8.57.1...v9.39.1)

---
updated-dependencies:
- dependency-name: eslint
  dependency-version: 9.39.1
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-06 20:00:14 +00:00
Manish Madan
4ff99a1e86 Merge pull request #2138 from arc53/dependabot/npm_and_yarn/frontend/reduxjs/toolkit-2.10.1
chore(deps): bump @reduxjs/toolkit from 2.9.2 to 2.10.1 in /frontend
2025-11-07 01:28:58 +05:30
dependabot[bot]
129084ba92 chore(deps): bump @reduxjs/toolkit from 2.9.2 to 2.10.1 in /frontend
Bumps [@reduxjs/toolkit](https://github.com/reduxjs/redux-toolkit) from 2.9.2 to 2.10.1.
- [Release notes](https://github.com/reduxjs/redux-toolkit/releases)
- [Commits](https://github.com/reduxjs/redux-toolkit/compare/v2.9.2...v2.10.1)

---
updated-dependencies:
- dependency-name: "@reduxjs/toolkit"
  dependency-version: 2.10.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-06 19:56:28 +00:00
Manish Madan
2288df1293 Merge pull request #2141 from arc53/dependabot/npm_and_yarn/frontend/vite-7.2.0
chore(deps-dev): bump vite from 7.1.12 to 7.2.0 in /frontend
2025-11-07 01:05:29 +05:30
Manish Madan
d9dfac55e7 Merge pull request #2134 from arc53/dependabot/npm_and_yarn/frontend/types/mermaid-9.2.0
chore(deps-dev): bump @types/mermaid from 9.1.0 to 9.2.0 in /frontend
2025-11-06 17:46:59 +05:30
Nick
404cf4b7c7 Update quickstart.mdx (#2142)
Added missing **
2025-11-06 12:37:27 +02:00
dependabot[bot]
f1c1fc123b chore(deps-dev): bump vite from 7.1.12 to 7.2.0 in /frontend
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.1.12 to 7.2.0.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.2.0/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 7.2.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-05 20:08:29 +00:00
ManishMadan2882
9f19c7ee4c Remove deprecated @types/mermaid dependency - mermaid provides its own types 2025-11-05 20:43:47 +05:30
dependabot[bot]
155e74eca1 chore(deps-dev): bump @types/mermaid from 9.1.0 to 9.2.0 in /frontend
Bumps [@types/mermaid](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/mermaid) from 9.1.0 to 9.2.0.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/mermaid)

---
updated-dependencies:
- dependency-name: "@types/mermaid"
  dependency-version: 9.2.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-05 15:10:19 +00:00
Manish Madan
ea2dc4dbcb Merge pull request #2133 from arc53/dependabot/npm_and_yarn/frontend/react-i18next-16.2.4
chore(deps): bump react-i18next from 15.7.4 to 16.2.4 in /frontend
2025-11-05 20:23:15 +05:30
dependabot[bot]
616edc97de chore(deps): bump react-i18next from 15.7.4 to 16.2.4 in /frontend
Bumps [react-i18next](https://github.com/i18next/react-i18next) from 15.7.4 to 16.2.4.
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v15.7.4...v16.2.4)

---
updated-dependencies:
- dependency-name: react-i18next
  dependency-version: 16.2.4
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-05 14:48:28 +00:00
Manish Madan
b017e99c79 Merge pull request #2132 from arc53/dependabot/npm_and_yarn/frontend/eslint-plugin-n-17.23.1
chore(deps-dev): bump eslint-plugin-n from 16.6.2 to 17.23.1 in /frontend
2025-11-05 20:14:18 +05:30
dependabot[bot]
f698e9d3e1 chore(deps-dev): bump eslint-plugin-n in /frontend
Bumps [eslint-plugin-n](https://github.com/eslint-community/eslint-plugin-n) from 16.6.2 to 17.23.1.
- [Release notes](https://github.com/eslint-community/eslint-plugin-n/releases)
- [Changelog](https://github.com/eslint-community/eslint-plugin-n/blob/master/CHANGELOG.md)
- [Commits](https://github.com/eslint-community/eslint-plugin-n/compare/16.6.2...v17.23.1)

---
updated-dependencies:
- dependency-name: eslint-plugin-n
  dependency-version: 17.23.1
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-05 14:35:17 +00:00
Manish Madan
d366502850 Merge pull request #2131 from arc53/dependabot/npm_and_yarn/frontend/typescript-eslint/parser-8.46.3
chore(deps-dev): bump @typescript-eslint/parser from 6.21.0 to 8.46.3 in /frontend
2025-11-05 20:03:59 +05:30
ManishMadan2882
3d6757c170 (chore:lint) relax rules, build fix 2025-11-05 20:02:01 +05:30
Manish Madan
cb8302add8 Fixes shared conversation on cloud version (#2135)
* (fix:shared) conv as id, not dbref

* (chore) script to migrate dbref to id

* (chore): ruff fix

---------

Co-authored-by: GH Action - Upstream Sync <action@github.com>
2025-11-05 16:08:10 +02:00
dependabot[bot]
9d266e9fad chore(deps-dev): bump @typescript-eslint/parser in /frontend
Bumps [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) from 6.21.0 to 8.46.3.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.46.3/packages/parser)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/parser"
  dependency-version: 8.46.3
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-05 13:45:18 +00:00
Manish Madan
ae94c9d31e Merge pull request #2130 from arc53/dependabot/npm_and_yarn/frontend/vite-7.1.12
chore(deps-dev): bump vite from 6.4.1 to 7.1.12 in /frontend
2025-11-05 19:13:59 +05:30
ManishMadan2882
83ab232dcd (chore:fe) pkg lock 2025-11-05 19:12:20 +05:30
dependabot[bot]
eea85772a3 chore(deps-dev): bump vite from 6.4.1 to 7.1.12 in /frontend
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 6.4.1 to 7.1.12.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v7.1.12/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.1.12/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 7.1.12
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-05 19:10:27 +05:30
Alex
0fe7e223cc fix: update Discord invite link across documentation and navigation 2025-11-04 09:27:22 +00:00
Heisenberg Vader
3789d2eb03 Updated the technique for handling multiple file uploads from the user (#2126)
* Fixed multiple file uploads to be sent through a single request to backend for further processing and storing

* Fixed multiple file uploads to be sent through a single request to backend for further processing and storing

* Fixed multiple file uploads to be sent through a single request to backend for further processing and storing

* Made duplicate multiple keyword fixes

* Added back drag and drop functionality and it keeps the multiple file uploads
2025-11-04 01:12:35 +02:00
Manish Madan
d54469532e fix: adjust ESLint rules to warnings for strict type checking (#2129)
- Changed @typescript-eslint/no-explicit-any from error to warning
- Changed @typescript-eslint/no-unused-vars from error to warning
- Allows codebase to pass linting while maintaining code quality checks
- These rules can be gradually enforced as code is refactored
- Verified with npm run build - successful
2025-11-04 01:09:39 +02:00
Manish Madan
9884e51836 Merge pull request #2122 from arc53/dependabot/npm_and_yarn/frontend/prettier-plugin-tailwindcss-0.7.1
chore(deps-dev): bump prettier-plugin-tailwindcss from 0.6.13 to 0.7.1 in /frontend
2025-11-03 19:31:30 +05:30
Alex
6626723180 feat: enhance prompt variable handling and add system variable options in prompts modal (#2128) 2025-11-03 15:54:13 +02:00
Manish Madan
0c251e066b Merge pull request #2124 from arc53/dependabot/npm_and_yarn/frontend/eslint-plugin-n-17.23.1
chore(deps-dev): bump eslint-plugin-n from 15.7.0 to 17.23.1 in /frontend
2025-11-03 19:22:22 +05:30
dependabot[bot]
0957034bfa chore(deps-dev): bump prettier-plugin-tailwindcss in /frontend
Bumps [prettier-plugin-tailwindcss](https://github.com/tailwindlabs/prettier-plugin-tailwindcss) from 0.6.13 to 0.7.1.
- [Release notes](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/prettier-plugin-tailwindcss/compare/v0.6.13...v0.7.1)

---
updated-dependencies:
- dependency-name: prettier-plugin-tailwindcss
  dependency-version: 0.7.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-03 13:49:34 +00:00
ManishMadan2882
44521cd893 fix: resolve peer dependency conflict with eslint-plugin-n
- Downgrade eslint-plugin-n from ^17.23.1 to ^16.6.2
- Ensure compatibility with eslint-config-standard-with-typescript@43.0.1
- eslint-config-standard-with-typescript requires eslint-plugin-n@^15.0.0 || ^16.0.0
- Verified with successful npm install and vite build
2025-11-03 19:19:02 +05:30
dependabot[bot]
b17f846730 chore(deps-dev): bump eslint-plugin-n in /frontend
Bumps [eslint-plugin-n](https://github.com/eslint-community/eslint-plugin-n) from 15.7.0 to 17.23.1.
- [Release notes](https://github.com/eslint-community/eslint-plugin-n/releases)
- [Changelog](https://github.com/eslint-community/eslint-plugin-n/blob/master/CHANGELOG.md)
- [Commits](https://github.com/eslint-community/eslint-plugin-n/compare/15.7.0...v17.23.1)

---
updated-dependencies:
- dependency-name: eslint-plugin-n
  dependency-version: 17.23.1
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-03 13:45:27 +00:00
Manish Madan
6dd32fd4ca Merge pull request #2111 from arc53/dependabot/npm_and_yarn/frontend/mermaid-11.12.1
chore(deps): bump mermaid from 11.12.0 to 11.12.1 in /frontend
2025-11-03 19:14:00 +05:30
dependabot[bot]
b17b1c70b5 chore(deps): bump mermaid from 11.12.0 to 11.12.1 in /frontend
Bumps [mermaid](https://github.com/mermaid-js/mermaid) from 11.12.0 to 11.12.1.
- [Release notes](https://github.com/mermaid-js/mermaid/releases)
- [Commits](https://github.com/mermaid-js/mermaid/compare/mermaid@11.12.0...mermaid@11.12.1)

---
updated-dependencies:
- dependency-name: mermaid
  dependency-version: 11.12.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-03 19:07:58 +05:30
Manish Madan
3f5b31fb5f Merge pull request #2084 from arc53/dependabot/npm_and_yarn/frontend/typescript-eslint/eslint-plugin-8.46.2
chore(deps-dev): bump @typescript-eslint/eslint-plugin from 5.51.0 to 8.46.2 in /frontend
2025-11-03 18:51:23 +05:30
ManishMadan2882
06bda6bd55 fix: resolve peer dependency conflicts in eslint and typescript-eslint packages
- Update @typescript-eslint/eslint-plugin from ^8.46.2 to ^6.21.0
- Update @typescript-eslint/parser from ^5.62.0 to ^6.21.0
- Update eslint-config-standard-with-typescript from ^34.0.0 to ^43.0.1
- Ensure all dependencies are compatible without requiring --legacy-peer-deps
- Verified with successful npm install and vite build
2025-11-03 18:47:34 +05:30
Christine Belzie
7dd97821a8 feat: Installing vale(redo) (#2104)
* docs: setup Vale

Signed-off-by: Christine Belzie <shecoder30@gmail.com>

* adding more content

* chore: add Vale configuration and custom dictionary

* chore: clean up spelling configuration and remove unused vocabularies

* fix: correct file path format for Vale linter configuration

---------

Signed-off-by: Christine Belzie <shecoder30@gmail.com>
Co-authored-by: Alex <a@tushynski.me>
2025-10-31 18:00:09 +02:00
Harshit Ranjan
695191d888 added error saving vector store (#2081)
* added error saving vector store

* fixed code formating

* added tests for embedding pipeline
2025-10-31 16:29:35 +02:00
dependabot[bot]
1dbcef24c7 chore(deps-dev): bump @typescript-eslint/eslint-plugin in /frontend
Bumps [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) from 5.51.0 to 8.46.2.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.46.2/packages/eslint-plugin)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-version: 8.46.2
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-31 14:04:19 +00:00
Manish Madan
e086c79da0 Merge pull request #1933 from arc53/dependabot/npm_and_yarn/frontend/npm_and_yarn-d56b2ef021
chore(deps): bump mermaid from 11.6.0 to 11.10.0 in /frontend in the npm_and_yarn group
2025-10-31 19:32:57 +05:30
dependabot[bot]
6ae8d34b27 chore(deps): bump mermaid in /frontend in the npm_and_yarn group
Bumps the npm_and_yarn group in /frontend with 1 update: [mermaid](https://github.com/mermaid-js/mermaid).

Updates `mermaid` from 11.6.0 to 11.10.0
- [Release notes](https://github.com/mermaid-js/mermaid/releases)
- [Commits](https://github.com/mermaid-js/mermaid/compare/mermaid@11.6.0...mermaid@11.10.0)

---
updated-dependencies:
- dependency-name: mermaid
  dependency-version: 11.10.0
  dependency-type: direct:production
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-31 19:27:31 +05:30
Manish Madan
2e23e547d3 Merge pull request #1916 from arc53/dependabot/npm_and_yarn/frontend/eslint-plugin-prettier-5.5.4
build(deps-dev): bump eslint-plugin-prettier from 5.2.1 to 5.5.4 in /frontend
2025-10-31 19:24:38 +05:30
dependabot[bot]
fa11dc9828 build(deps-dev): bump eslint-plugin-prettier in /frontend
Bumps [eslint-plugin-prettier](https://github.com/prettier/eslint-plugin-prettier) from 5.2.1 to 5.5.4.
- [Release notes](https://github.com/prettier/eslint-plugin-prettier/releases)
- [Changelog](https://github.com/prettier/eslint-plugin-prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/eslint-plugin-prettier/compare/v5.2.1...v5.5.4)

---
updated-dependencies:
- dependency-name: eslint-plugin-prettier
  dependency-version: 5.5.4
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-31 19:18:44 +05:30
Manish Madan
673fa70bc5 Merge pull request #1903 from arc53/dependabot/npm_and_yarn/frontend/multi-6fb5dc7d23
build(deps): bump react-dom and @types/react-dom in /frontend
2025-10-31 19:16:17 +05:30
dependabot[bot]
a0660a54c1 build(deps): bump react-dom and @types/react-dom in /frontend
Bumps [react-dom](https://github.com/facebook/react/tree/HEAD/packages/react-dom) and [@types/react-dom](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-dom). These dependencies needed to be updated together.

Updates `react-dom` from 19.1.0 to 19.1.1
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.1.1/packages/react-dom)

Updates `@types/react-dom` from 19.1.6 to 19.1.7
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-dom)

---
updated-dependencies:
- dependency-name: react-dom
  dependency-version: 19.1.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: "@types/react-dom"
  dependency-version: 19.1.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-31 19:10:51 +05:30
Manish Madan
1137bf4280 Merge pull request #1864 from arc53/dependabot/npm_and_yarn/frontend/i18next-browser-languagedetector-8.2.0
build(deps): bump i18next-browser-languagedetector from 8.0.2 to 8.2.0 in /frontend
2025-10-31 19:10:26 +05:30
dependabot[bot]
da41c898d8 build(deps): bump i18next-browser-languagedetector in /frontend
Bumps [i18next-browser-languagedetector](https://github.com/i18next/i18next-browser-languageDetector) from 8.0.2 to 8.2.0.
- [Changelog](https://github.com/i18next/i18next-browser-languageDetector/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next-browser-languageDetector/compare/v8.0.2...v8.2.0)

---
updated-dependencies:
- dependency-name: i18next-browser-languagedetector
  dependency-version: 8.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-31 18:59:21 +05:30
Siddhant Rai
21e5c261ef feat: template-based prompt rendering with dynamic namespace injection (#2091)
* feat: template-based prompt rendering with dynamic namespace injection

* refactor: improve template engine initialization with clearer formatting

* refactor: streamline ReActAgent methods and improve content extraction logic

feat: enhance error handling in NamespaceManager and TemplateEngine

fix: update NewAgent component to ensure consistent form data submission

test: modify tests for ReActAgent and prompt renderer to reflect method changes and improve coverage

* feat: tools namespace + three-tier token budget

* refactor: remove unused variable assignment in message building tests

* Enhance prompt customization and tool pre-fetching functionality

* ruff lint fix

* refactor: cleaner error handling and reduce code clutter

---------

Co-authored-by: Alex <a@tushynski.me>
2025-10-31 12:47:44 +00:00
Aqsa Aqeel
a7d61b9d59 feat: implementing the new custom modal design (#2090)
* feat: implementing the new custom modal design

* feat: added tool variable dropdown

* fix: ui fixes and link fixes

* feat: implemented redisgn for edit prompt modal

* (feat:prompts) matching figma

* (fix:prompts) tool vars

* (fix:promptsModal) responsive; disable save on text

---------

Co-authored-by: Aqsa Aqeel <aqsa.aqeel17@example.com>
Co-authored-by: ManishMadan2882 <manishmadan321@gmail.com>
2025-10-31 12:18:13 +02:00
dorkdiaries9
c5fe25c149 Enhance migration script with logging and error handling (#2103)
Added logging for migration steps and error handling.
2025-10-29 01:49:47 +02:00
Manish Madan
6a4cb617f9 Frontend audit: Bug fixes and refinements (#2112)
* (fix:attachements) sep id for redux ops

* (fix:ui) popups, toast, share modal

* (feat:agentsPreview) stable preview, ui fixes

* (fix:ui) light theme icon, sleek scroll

* (chore:i18n) missin keys

* (chore:i18n) missing keys

* (feat:preferrenceSlice) autoclear invalid source from storage

* (fix:general) delete all conv close btn

* (fix:tts) play one at a time

* (fix:tts) gracefully unmount

* (feat:tts) audio LRU cache

* (feat:tts) pointer on hovered area

* (feat:tts) clean text for speach

---------

Co-authored-by: GH Action - Upstream Sync <action@github.com>
2025-10-29 01:47:26 +02:00
Alex
94f70e6de5 refactor(tests): update todo tool tests to use simplified action names and improve key handling 2025-10-28 10:39:35 +00:00
Hanzalah Waheed
ab4ebf9a9d feat: add bg blur for modals (#2110)
* feat: add bg blur for modals

* feat: adjust darkness for lightmode
2025-10-28 12:14:37 +02:00
Nikunj Kohli
9f7945fcf5 [UI/UX] Improve image upload experience — add preview & drag-to-reord… (#2095)
* [UI/UX] Improve image upload experience — add preview & drag-to-reorder in chat section

* chore(chat): remove image previews, keep drag-to-reorder

* chore(chat): prevent attachment drag from triggering upload dropzone

* Revert "chore(chat): prevent attachment drag from triggering upload dropzone"

This reverts commit dd4b96256c.

* (feat:conv) rmv drag-drop on sources

* (feat:msg-input) drop attachments

---------

Co-authored-by: ManishMadan2882 <manishmadan321@gmail.com>
2025-10-27 21:53:18 +02:00
Gayatri K
d8ec3c008c todo tool feature added to tools (#1977)
* todo tool feature added to tools

* removed configs

* fix: require user_id on TodoListTool, normalize timestamps, add tests

* lint and tests fixes

* refactor: support multiple todos per user/tool by indexing with todo_id

* modified todo_id to use auto-increamenting integer instead of UUID

* test-case fixes

* feat: fix todos

---------

Co-authored-by: Alex <a@tushynski.me>
2025-10-27 19:09:32 +02:00
Pavel
2f00691246 Merge pull request #2096 from arc53/hacktoberfest-t-shirt-image
Update HACKTOBERFEST.md with T-shirt image
2025-10-24 13:15:30 +01:00
Pavel
9b2383b074 Update HACKTOBERFEST.md with T-shirt image
Added t-shirt image.
2025-10-24 13:09:23 +01:00
Ritoban Dutta
e4e9910575 fix(ui): use dedicated sidebar open/close icons for better visual feedback of actions (#2088)
* fix(ui): use dedicated icons for sidebar toggle (panel-left-open/close)

* fix: update sidebar toggle icon colors
2025-10-24 00:25:17 +03:00
Nihar
f448e4a615 add configurable provider in settings.py and update ElevenLabs Api (#2065) (#2074) 2025-10-22 19:07:21 +03:00
Manish Madan
c4e8daf50e Frontend audit: refinements (#2083)
* (fix:attachements) sep id for redux ops

* (fix:ui) popups, toast, share modal

* (feat:agentsPreview) stable preview, ui fixes

* (fix:ui) light theme icon, sleek scroll

---------

Co-authored-by: GH Action - Upstream Sync <action@github.com>
2025-10-22 12:12:05 +03:00
Hanzalah Waheed
5aa4ec1b9f fix: cleanup ConversationBubble and fix CopyButton (#2073)
* fix: rm states for hovering. use tailwind classes instead

* fix: use group hover css intead of states

* chore: no point in having separate defaults if cant be customised

* fix: move default bg colors into conditionals
2025-10-18 21:59:15 +03:00
Siddhant Rai
125ce0aad3 test: implement full API test suite with mongomock and centralized fixtures (#2068) 2025-10-17 12:01:14 +03:00
TinTin
ababc9ae04 fix: reduce large margins between list items in answer rendering (#2058) 2025-10-16 13:59:45 +03:00
Alex
62ac90746e fix: improve error handling and loading state in fetchChunks function 2025-10-15 17:33:13 +01:00
Alex
096f6d91a2 fix: handle potential undefined value for selectedDocs in fetchAnswer 2025-10-15 17:12:52 +01:00
jay98
d28ef6b094 Refactor: use async/await in fetchChunks for correct error handling (typescript:S4822) (#2066) 2025-10-15 15:52:55 +03:00
Anurag Yadav
8fb945ab09 feedback button to show after message (#2064) 2025-10-14 18:52:31 +03:00
jay98
835d71727c fix: remove redundant conditional operator for file assignment (#2060) 2025-10-14 17:35:41 +03:00
Ali Arda Fincan
ce32dd2907 Feat: Agent Token or Request Limiting (#2041)
* Update routes.py, added token and request limits to create/update agent operations

* added usage limit check to api endpoints

cannot create agents with usage limit right now that will be implemented

* implemented api limiting as either token limiting or request limiting modes

* minor typo & bug fix
2025-10-13 21:32:46 +03:00
Manish Madan
72bc24a490 Chore: deleted unused files, dead code; minor fixes (#2059)
* (feat:pause-stream) generator exit

* (feat:pause-stream) close request

* (feat:pause-stream) finally close; google anthropic

* (feat:task_status)communicate failure

* (clean:connector) unused routes

* (feat:file-table) missing skeletons

* (chore:fe) purge unused

* (fix:apiKeys) build err

* (chore:settings) clean unused

* merge from main

* (chore:purge) unused fe assets

* (clean:check_docs) unused logic

* (feat:selectedDocs) replace null type

---------

Co-authored-by: GH Action - Upstream Sync <action@github.com>
2025-10-13 19:11:24 +03:00
Siddhant Rai
d6c49bdbf0 test: add agent test coverage and standardize test suite (#2051)
- Add 104 comprehensive tests for agent system
- Integrate agent tests into CI/CD pipeline
- Standardize tests with @pytest.mark.unit markers
- Fix cross-platform path compatibility
- Clean up unused imports and dependencies
2025-10-13 14:43:35 +03:00
beKool.sh
1805292528 Update README.md (#2057) 2025-10-13 13:00:13 +03:00
Nirjas Jakilim
d09ce7e1f7 fixed broken link (#2054) 2025-10-12 15:26:05 +03:00
Marco Ponce
a8d2024791 Windows deployment powershell and renamed LLM_PROVIDER and runtime (#2050)
* Windows deployment powershell and  renamed LLM_PROVIDER and runtime

* added LLM_NAME back

* revert changes on docker-compose-hub.yaml
2025-10-12 15:25:42 +03:00
Manish Madan
f0b954dbfb Upload: communicate failure, minor frontend updates (#2048)
* (feat:pause-stream) generator exit

* (feat:pause-stream) close request

* (feat:pause-stream) finally close; google anthropic

* (feat:task_status)communicate failure

* (clean:connector) unused routes

* (feat:file-table) missing skeletons

* (fix:apiKeys) build err

---------

Co-authored-by: GH Action - Upstream Sync <action@github.com>
2025-10-10 17:34:02 +03:00
Rahul
50bee7c2b0 feat: Add button to cancel LLM response (#1978)
* feat: Add button to cancel LLM response
- Replace text area with cancel button when loading.
- Add useEffect to change elipsis in cancel button text.
- Add new SVG icon for cancel response.
- Button colors match Figma designs.

* fix: Cancel button UI matches new design
- Delete cancel-response svg.
- Change previous cancel button to match the new Figma design.
- Remove console log in handleCancel function.

* fix: Adjust cancel button rounding

* feat: Update UI for send button
- Add SendArrowIcon component, enables dynamic svg color changes
- Replace original icon
- Update colors and hover effects

* (fix:send-button) minor blink in transition

---------

Co-authored-by: Manish Madan <manishmadan321@gmail.com>
2025-10-09 12:01:25 +03:00
Mariam Saeed
e7b15b316e Feat: Notification section (#2033)
* Feature/Notification-section

* fix notification ui and add local storage variable to save the state

* add notification component to app.tsx
2025-10-09 01:26:10 +03:00
Manish Madan
a4507008c1 complete_stream: Stop response streaming (#2031)
* (feat:pause-stream) generator exit

* (feat:pause-stream) close request

* (feat:pause-stream) finally close; google anthropic

---------

Co-authored-by: GH Action - Upstream Sync <action@github.com>
2025-10-08 20:37:30 +03:00
Hanzalah Waheed
c5ba85f929 fix(ui): create a var to check for shared metadata obj (#2040) 2025-10-08 18:18:54 +03:00
Manish Madan
2e636bd67e Merge pull request #1970 from arc53/dependabot/npm_and_yarn/frontend/mermaid-11.12.0
chore(deps): bump mermaid from 11.6.0 to 11.12.0 in /frontend
2025-10-08 20:42:32 +05:30
dependabot[bot]
4a039f1abf chore(deps): bump mermaid from 11.6.0 to 11.12.0 in /frontend
Bumps [mermaid](https://github.com/mermaid-js/mermaid) from 11.6.0 to 11.12.0.
- [Release notes](https://github.com/mermaid-js/mermaid/releases)
- [Commits](https://github.com/mermaid-js/mermaid/compare/mermaid@11.6.0...mermaid@11.12.0)

---
updated-dependencies:
- dependency-name: mermaid
  dependency-version: 11.12.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-08 15:06:36 +00:00
JeevaRamanathan
434d8e2070 fix spinner to match theme (dark/light) in conversation (#2044) 2025-10-08 15:16:44 +03:00
Nihar
160ad2dc79 correct path for storing embeddings model (#2035) 2025-10-07 23:01:39 +03:00
Anshuman Payasi
0ec86c2c71 fix: adjust share modal size and spacing (#2027)
* fix/share-modal-spacing

* fix(share-modal): spacing adjusted for mobile view
2025-10-07 17:11:03 +03:00
Alex
03452ffd9f feat: add GitHub access token support and fix file content fetching logic (#2032) 2025-10-07 16:53:14 +03:00
Siddhant Rai
da6317a242 feat: agent templates and seeding premade agents (#1910)
* feat: agent templates and seeding premade agents

* fix: ensure ObjectId is used for source reference in agent configuration

* fix: improve source handling in DatabaseSeeder and update tool config processing

* feat: add prompt handling in DatabaseSeeder for agent configuration

* Docs premade agents

* link to prescraped docs

* feat: add template agent retrieval and adopt agent functionality

* feat: simplify agent descriptions in premade_agents.yaml  added docs

---------

Co-authored-by: Pavel <pabin@yandex.ru>
Co-authored-by: Alex <a@tushynski.me>
2025-10-07 13:00:14 +03:00
Nihar
8b8e616557 fix: handle missing kwargs in local save_file (#2017)
Previously, the local save_file function didn’t accept kwargs, causing
a crash when passed extra params. Added support to maintain consistency
with AWS version.

Fixes #2009
2025-10-06 23:55:49 +03:00
Marco Ponce
d260f1a1a6 Made changes to how the documentation is represented including the new 5th option when forking and launching DocsGPT on the inviduals device, as well as updated README.md which has miswording issues saying only 4 options, but now includes the 5th option, detailing and giving an explanation for what that option does and documentation provided. (#2020) 2025-10-06 23:50:16 +03:00
Alex
9d452e3b04 feat: enhance MemoryTool and NotesTool with tool_id management and directory renaming tests (#2026) 2025-10-06 23:45:47 +03:00
227 changed files with 21241 additions and 9030 deletions

View File

@@ -13,7 +13,11 @@ updates:
directory: "/frontend" # Location of package manifests
schedule:
interval: "daily"
- package-ecosystem: "npm"
directory: "/extensions/react-widget"
schedule:
interval: "daily"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
interval: "daily"

11
.github/styles/DocsGPT/Spelling.yml vendored Normal file
View File

@@ -0,0 +1,11 @@
extends: spelling
level: warning
message: "Did you really mean '%s'?"
ignore:
- "**/node_modules/**"
- "**/dist/**"
- "**/build/**"
- "**/coverage/**"
- "**/public/**"
- "**/static/**"
vocab: DocsGPT

View File

@@ -0,0 +1,46 @@
Ollama
Qdrant
Milvus
Chatwoot
Nextra
VSCode
npm
LLMs
APIs
Groq
SGLang
LMDeploy
OAuth
Vite
LLM
JSONPath
UIs
configs
uncomment
qdrant
vectorstore
docsgpt
llm
GPUs
kubectl
Lightsail
enqueues
chatbot
VSCode's
Shareability
feedbacks
automations
Premade
Signup
Repo
repo
env
URl
agentic
llama_cpp
parsable
SDKs
boolean
bool
hardcode
EOL

View File

@@ -16,15 +16,15 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pytest pytest-cov
cd application
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
cd ../tests
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
- name: Test with pytest and generate coverage report
run: |
python -m pytest --cov=application --cov-report=xml
python -m pytest --cov=application --cov-report=xml --cov-report=term-missing
- name: Upload coverage reports to Codecov
if: github.event_name == 'pull_request' && matrix.python-version == '3.12'
uses: codecov/codecov-action@v5
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}

26
.github/workflows/vale.yml vendored Normal file
View File

@@ -0,0 +1,26 @@
name: Vale Documentation Linter
on:
pull_request:
paths:
- 'docs/**/*.md'
- 'docs/**/*.mdx'
- '**/*.md'
- '.vale.ini'
- '.github/styles/**'
jobs:
vale:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Vale linter
uses: errata-ai/vale-action@v2
with:
files: docs
fail_on_error: false
version: 3.0.5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

1
.gitignore vendored
View File

@@ -3,6 +3,7 @@ __pycache__/
*.py[cod]
*$py.class
experiments
# C extensions
*.so
*.next

5
.vale.ini Normal file
View File

@@ -0,0 +1,5 @@
MinAlertLevel = warning
StylesPath = .github/styles
[*.{md,mdx}]
BasedOnStyles = DocsGPT

View File

@@ -147,5 +147,5 @@ Here's a step-by-step guide on how to contribute to DocsGPT:
Thank you for considering contributing to DocsGPT! 🙏
## Questions/collaboration
Feel free to join our [Discord](https://discord.gg/n5BX8dh8rU). We're very friendly and welcoming to new contributors, so don't hesitate to reach out.
Feel free to join our [Discord](https://discord.gg/vN7YFfdMpj). We're very friendly and welcoming to new contributors, so don't hesitate to reach out.
# Thank you so much for considering to contributing DocsGPT!🙏

View File

@@ -3,6 +3,7 @@
Welcome, contributors! We're excited to announce that DocsGPT is participating in Hacktoberfest. Get involved by submitting meaningful pull requests.
All Meaningful contributors with accepted PRs that were created for issues with the `hacktoberfest` label (set by our maintainer team: dartpain, siiddhantt, pabik, ManishMadan2882) will receive a cool T-shirt! 🤩.
<img width="1331" height="678" alt="hacktoberfest-mocks-preview" src="https://github.com/user-attachments/assets/633f6377-38db-48f5-b519-a8b3855a9eb4" />
Fill in [this form](https://forms.gle/Npaba4n9Epfyx56S8
) after your PR was merged please
@@ -31,7 +32,7 @@ Non-Code Contributions:
- Before contributing check existing [issues](https://github.com/arc53/DocsGPT/issues) or [create](https://github.com/arc53/DocsGPT/issues/new/choose) an issue and wait to get assigned.
- Once you are finished with your contribution, please fill in this [form](https://forms.gle/Npaba4n9Epfyx56S8).
- Refer to the [Documentation](https://docs.docsgpt.cloud/).
- Feel free to join our [Discord](https://discord.gg/n5BX8dh8rU) server. We're here to help newcomers, so don't hesitate to jump in! Join us [here](https://discord.gg/n5BX8dh8rU).
- Feel free to join our [Discord](https://discord.gg/vN7YFfdMpj) server. We're here to help newcomers, so don't hesitate to jump in! Join us [here](https://discord.gg/vN7YFfdMpj).
Thank you very much for considering contributing to DocsGPT during Hacktoberfest! 🙏 Your contributions (not just simple typos) could earn you a stylish new t-shirt.

View File

@@ -16,10 +16,10 @@
<a href="https://github.com/arc53/DocsGPT">![link to main GitHub showing Forks number](https://img.shields.io/github/forks/arc53/docsgpt?style=social)</a>
<a href="https://github.com/arc53/DocsGPT/blob/main/LICENSE">![link to license file](https://img.shields.io/github/license/arc53/docsgpt)</a>
<a href="https://www.bestpractices.dev/projects/9907"><img src="https://www.bestpractices.dev/projects/9907/badge"></a>
<a href="https://discord.gg/n5BX8dh8rU">![link to discord](https://img.shields.io/discord/1070046503302877216)</a>
<a href="https://discord.gg/vN7YFfdMpj">![link to discord](https://img.shields.io/discord/1070046503302877216)</a>
<a href="https://x.com/docsgptai">![X (formerly Twitter) URL](https://img.shields.io/twitter/follow/docsgptai)</a>
<a href="https://docs.docsgpt.cloud/quickstart">⚡️ Quickstart</a><a href="https://app.docsgpt.cloud/">☁️ Cloud Version</a><a href="https://discord.gg/n5BX8dh8rU">💬 Discord</a>
<a href="https://docs.docsgpt.cloud/quickstart">⚡️ Quickstart</a><a href="https://app.docsgpt.cloud/">☁️ Cloud Version</a><a href="https://discord.gg/vN7YFfdMpj">💬 Discord</a>
<br>
<a href="https://docs.docsgpt.cloud/">📖 Documentation</a><a href="https://github.com/arc53/DocsGPT/blob/main/CONTRIBUTING.md">👫 Contribute</a><a href="https://blog.docsgpt.cloud/">🗞 Blog</a>
<br>
@@ -68,7 +68,7 @@
- [x] MCP support (August 2025)
- [x] Google Drive integration (September 2025)
- [x] Add OAuth 2.0 authentication for MCP (September 2025)
- [ ] Sharepoint integration (October 2025)
- [ ] SharePoint integration (October 2025)
- [ ] Deep Agents (October 2025)
- [ ] Agent scheduling
@@ -118,7 +118,7 @@ A more detailed [Quickstart](https://docs.docsgpt.cloud/quickstart) is available
PowerShell -ExecutionPolicy Bypass -File .\setup.ps1
```
Either script will guide you through setting up DocsGPT. Four options available: using the public API, running locally, connecting to a local inference engine, or using a cloud API provider. Scripts will automatically configure your `.env` file and handle necessary downloads and installations based on your chosen option.
Either script will guide you through setting up DocsGPT. Five options available: using the public API, running locally, connecting to a local inference engine, using a cloud API provider, or build the docker image locally. Scripts will automatically configure your `.env` file and handle necessary downloads and installations based on your chosen option.
**Navigate to http://localhost:5173/**

View File

@@ -1,5 +1,8 @@
from application.agents.classic_agent import ClassicAgent
from application.agents.react_agent import ReActAgent
import logging
logger = logging.getLogger(__name__)
class AgentCreator:
@@ -13,4 +16,5 @@ class AgentCreator:
agent_class = cls.agents.get(type.lower())
if not agent_class:
raise ValueError(f"No agent class found for type {type}")
return agent_class(*args, **kwargs)

View File

@@ -12,7 +12,6 @@ from application.core.settings import settings
from application.llm.handlers.handler_creator import LLMHandlerCreator
from application.llm.llm_creator import LLMCreator
from application.logging import build_stack_data, log_activity, LogContext
from application.retriever.base import BaseRetriever
logger = logging.getLogger(__name__)
@@ -22,23 +21,28 @@ class BaseAgent(ABC):
self,
endpoint: str,
llm_name: str,
gpt_model: str,
model_id: str,
api_key: str,
user_api_key: Optional[str] = None,
prompt: str = "",
chat_history: Optional[List[Dict]] = None,
retrieved_docs: Optional[List[Dict]] = None,
decoded_token: Optional[Dict] = None,
attachments: Optional[List[Dict]] = None,
json_schema: Optional[Dict] = None,
limited_token_mode: Optional[bool] = False,
token_limit: Optional[int] = settings.DEFAULT_AGENT_LIMITS["token_limit"],
limited_request_mode: Optional[bool] = False,
request_limit: Optional[int] = settings.DEFAULT_AGENT_LIMITS["request_limit"],
):
self.endpoint = endpoint
self.llm_name = llm_name
self.gpt_model = gpt_model
self.model_id = model_id
self.api_key = api_key
self.user_api_key = user_api_key
self.prompt = prompt
self.decoded_token = decoded_token or {}
self.user: str = decoded_token.get("sub")
self.user: str = self.decoded_token.get("sub")
self.tool_config: Dict = {}
self.tools: List[Dict] = []
self.tool_calls: List[Dict] = []
@@ -48,22 +52,28 @@ class BaseAgent(ABC):
api_key=api_key,
user_api_key=user_api_key,
decoded_token=decoded_token,
model_id=model_id,
)
self.retrieved_docs = retrieved_docs or []
self.llm_handler = LLMHandlerCreator.create_handler(
llm_name if llm_name else "default"
)
self.attachments = attachments or []
self.json_schema = json_schema
self.limited_token_mode = limited_token_mode
self.token_limit = token_limit
self.limited_request_mode = limited_request_mode
self.request_limit = request_limit
@log_activity()
def gen(
self, query: str, retriever: BaseRetriever, log_context: LogContext = None
self, query: str, log_context: LogContext = None
) -> Generator[Dict, None, None]:
yield from self._gen_inner(query, retriever, log_context)
yield from self._gen_inner(query, log_context)
@abstractmethod
def _gen_inner(
self, query: str, retriever: BaseRetriever, log_context: LogContext
self, query: str, log_context: LogContext
) -> Generator[Dict, None, None]:
pass
@@ -142,6 +152,7 @@ class BaseAgent(ABC):
call_id = getattr(call, "id", None) or str(uuid.uuid4())
# Check if parsing failed
if tool_id is None or action_name is None:
error_message = f"Error: Failed to parse LLM tool call. Tool name: {getattr(call, 'name', 'unknown')}"
logger.error(error_message)
@@ -156,13 +167,14 @@ class BaseAgent(ABC):
yield {"type": "tool_call", "data": {**tool_call_data, "status": "error"}}
self.tool_calls.append(tool_call_data)
return "Failed to parse tool call.", call_id
# Check if tool_id exists in available tools
if tool_id not in tools_dict:
error_message = f"Error: Tool ID '{tool_id}' extracted from LLM call not found in available tools_dict. Available IDs: {list(tools_dict.keys())}"
logger.error(error_message)
# Return error result
tool_call_data = {
"tool_name": "unknown",
"call_id": call_id,
@@ -173,7 +185,6 @@ class BaseAgent(ABC):
yield {"type": "tool_call", "data": {**tool_call_data, "status": "error"}}
self.tool_calls.append(tool_call_data)
return f"Tool with ID {tool_id} not found.", call_id
tool_call_data = {
"tool_name": tools_dict[tool_id]["name"],
"call_id": call_id,
@@ -215,6 +226,7 @@ class BaseAgent(ABC):
tm = ToolManager(config={})
# Prepare tool_config and add tool_id for memory tools
if tool_data["name"] == "api_tool":
tool_config = {
"url": tool_data["config"]["actions"][action_name]["url"],
@@ -226,8 +238,8 @@ class BaseAgent(ABC):
tool_config = tool_data["config"].copy() if tool_data["config"] else {}
# Add tool_id from MongoDB _id for tools that need instance isolation (like memory tool)
# Use MongoDB _id if available, otherwise fall back to enumerated tool_id
tool_config["tool_id"] = str(tool_data.get("_id", tool_id))
tool_config["tool_id"] = str(tool_data.get("_id", tool_id))
tool = tm.load_tool(
tool_data["name"],
tool_config=tool_config,
@@ -268,24 +280,14 @@ class BaseAgent(ABC):
self,
system_prompt: str,
query: str,
retrieved_data: List[Dict],
) -> List[Dict]:
docs_with_filenames = []
for doc in retrieved_data:
filename = doc.get("filename") or doc.get("title") or doc.get("source")
if filename:
chunk_header = str(filename)
docs_with_filenames.append(f"{chunk_header}\n{doc['text']}")
else:
docs_with_filenames.append(doc["text"])
docs_together = "\n\n".join(docs_with_filenames)
p_chat_combine = system_prompt.replace("{summaries}", docs_together)
messages_combine = [{"role": "system", "content": p_chat_combine}]
"""Build messages using pre-rendered system prompt"""
messages = [{"role": "system", "content": system_prompt}]
for i in self.chat_history:
if "prompt" in i and "response" in i:
messages_combine.append({"role": "user", "content": i["prompt"]})
messages_combine.append({"role": "assistant", "content": i["response"]})
messages.append({"role": "user", "content": i["prompt"]})
messages.append({"role": "assistant", "content": i["response"]})
if "tool_calls" in i:
for tool_call in i["tool_calls"]:
call_id = tool_call.get("call_id") or str(uuid.uuid4())
@@ -305,29 +307,17 @@ class BaseAgent(ABC):
}
}
messages_combine.append(
messages.append(
{"role": "assistant", "content": [function_call_dict]}
)
messages_combine.append(
messages.append(
{"role": "tool", "content": [function_response_dict]}
)
messages_combine.append({"role": "user", "content": query})
return messages_combine
def _retriever_search(
self,
retriever: BaseRetriever,
query: str,
log_context: Optional[LogContext] = None,
) -> List[Dict]:
retrieved_data = retriever.search(query)
if log_context:
data = build_stack_data(retriever, exclude_attributes=["llm"])
log_context.stacks.append({"component": "retriever", "data": data})
return retrieved_data
messages.append({"role": "user", "content": query})
return messages
def _llm_gen(self, messages: List[Dict], log_context: Optional[LogContext] = None):
gen_kwargs = {"model": self.gpt_model, "messages": messages}
gen_kwargs = {"model": self.model_id, "messages": messages}
if (
hasattr(self.llm, "_supports_tools")
@@ -335,7 +325,6 @@ class BaseAgent(ABC):
and self.tools
):
gen_kwargs["tools"] = self.tools
if (
self.json_schema
and hasattr(self.llm, "_supports_structured_output")
@@ -349,7 +338,6 @@ class BaseAgent(ABC):
gen_kwargs["response_format"] = structured_format
elif self.llm_name == "google":
gen_kwargs["response_schema"] = structured_format
resp = self.llm.gen_stream(**gen_kwargs)
if log_context:

View File

@@ -1,32 +1,20 @@
import logging
from typing import Dict, Generator
from application.agents.base import BaseAgent
from application.logging import LogContext
from application.retriever.base import BaseRetriever
import logging
logger = logging.getLogger(__name__)
class ClassicAgent(BaseAgent):
"""A simplified agent with clear execution flow.
Usage:
1. Processes a query through retrieval
2. Sets up available tools
3. Generates responses using LLM
4. Handles tool interactions if needed
5. Returns standardized outputs
Easy to extend by overriding specific steps.
"""
"""A simplified agent with clear execution flow"""
def _gen_inner(
self, query: str, retriever: BaseRetriever, log_context: LogContext
self, query: str, log_context: LogContext
) -> Generator[Dict, None, None]:
# Step 1: Retrieve relevant data
retrieved_data = self._retriever_search(retriever, query, log_context)
"""Core generator function for ClassicAgent execution flow"""
# Step 2: Prepare tools
tools_dict = (
self._get_user_tools(self.user)
if not self.user_api_key
@@ -34,20 +22,16 @@ class ClassicAgent(BaseAgent):
)
self._prepare_tools(tools_dict)
# Step 3: Build and process messages
messages = self._build_messages(self.prompt, query, retrieved_data)
messages = self._build_messages(self.prompt, query)
llm_response = self._llm_gen(messages, log_context)
# Step 4: Handle the response
yield from self._handle_response(
llm_response, tools_dict, messages, log_context
)
# Step 5: Return metadata
yield {"sources": retrieved_data}
yield {"sources": self.retrieved_docs}
yield {"tool_calls": self._get_truncated_tool_calls()}
# Log tool calls for debugging
log_context.stacks.append(
{"component": "agent", "data": {"tool_calls": self.tool_calls.copy()}}
)

View File

@@ -1,229 +1,238 @@
import os
from typing import Dict, Generator, List, Any
import logging
import os
from typing import Any, Dict, Generator, List
from application.agents.base import BaseAgent
from application.logging import build_stack_data, LogContext
from application.retriever.base import BaseRetriever
logger = logging.getLogger(__name__)
MAX_ITERATIONS_REASONING = 10
current_dir = os.path.dirname(
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
)
with open(
os.path.join(current_dir, "application/prompts", "react_planning_prompt.txt"), "r"
) as f:
planning_prompt_template = f.read()
PLANNING_PROMPT_TEMPLATE = f.read()
with open(
os.path.join(current_dir, "application/prompts", "react_final_prompt.txt"),
"r",
os.path.join(current_dir, "application/prompts", "react_final_prompt.txt"), "r"
) as f:
final_prompt_template = f.read()
MAX_ITERATIONS_REASONING = 10
FINAL_PROMPT_TEMPLATE = f.read()
class ReActAgent(BaseAgent):
"""
Research and Action (ReAct) Agent - Advanced reasoning agent with iterative planning.
Implements a think-act-observe loop for complex problem-solving:
1. Creates a strategic plan based on the query
2. Executes tools and gathers observations
3. Iteratively refines approach until satisfied
4. Synthesizes final answer from all observations
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.plan: str = ""
self.observations: List[str] = []
def _extract_content_from_llm_response(self, resp: Any) -> str:
"""
Helper to extract string content from various LLM response types.
Handles strings, message objects (OpenAI-like), and streams.
Adapt stream handling for your specific LLM client if not OpenAI.
"""
collected_content = []
if isinstance(resp, str):
collected_content.append(resp)
elif ( # OpenAI non-streaming or Anthropic non-streaming (older SDK style)
hasattr(resp, "message")
and hasattr(resp.message, "content")
and resp.message.content is not None
):
collected_content.append(resp.message.content)
elif ( # OpenAI non-streaming (Pydantic model), Anthropic new SDK non-streaming
hasattr(resp, "choices") and resp.choices and
hasattr(resp.choices[0], "message") and
hasattr(resp.choices[0].message, "content") and
resp.choices[0].message.content is not None
):
collected_content.append(resp.choices[0].message.content) # OpenAI
elif ( # Anthropic new SDK non-streaming content block
hasattr(resp, "content") and isinstance(resp.content, list) and resp.content and
hasattr(resp.content[0], "text")
):
collected_content.append(resp.content[0].text) # Anthropic
else:
# Assume resp is a stream if not a recognized object
try:
for chunk in resp: # This will fail if resp is not iterable (e.g. a non-streaming response object)
content_piece = ""
# OpenAI-like stream
if hasattr(chunk, 'choices') and len(chunk.choices) > 0 and \
hasattr(chunk.choices[0], 'delta') and \
hasattr(chunk.choices[0].delta, 'content') and \
chunk.choices[0].delta.content is not None:
content_piece = chunk.choices[0].delta.content
# Anthropic-like stream (ContentBlockDelta)
elif hasattr(chunk, 'type') and chunk.type == 'content_block_delta' and \
hasattr(chunk, 'delta') and hasattr(chunk.delta, 'text'):
content_piece = chunk.delta.text
elif isinstance(chunk, str): # Simplest case: stream of strings
content_piece = chunk
if content_piece:
collected_content.append(content_piece)
except TypeError: # If resp is not iterable (e.g. a final response object that wasn't caught above)
logger.debug(f"Response type {type(resp)} could not be iterated as a stream. It might be a non-streaming object not handled by specific checks.")
except Exception as e:
logger.error(f"Error processing potential stream chunk: {e}, chunk was: {getattr(chunk, '__dict__', chunk)}")
return "".join(collected_content)
def _gen_inner(
self, query: str, retriever: BaseRetriever, log_context: LogContext
self, query: str, log_context: LogContext
) -> Generator[Dict, None, None]:
# Reset state for this generation call
self.plan = ""
self.observations = []
retrieved_data = self._retriever_search(retriever, query, log_context)
"""Execute ReAct reasoning loop with planning, action, and observation cycles"""
if self.user_api_key:
tools_dict = self._get_tools(self.user_api_key)
else:
tools_dict = self._get_user_tools(self.user)
self._reset_state()
tools_dict = (
self._get_tools(self.user_api_key)
if self.user_api_key
else self._get_user_tools(self.user)
)
self._prepare_tools(tools_dict)
docs_together = "\n".join([doc["text"] for doc in retrieved_data])
iterating_reasoning = 0
while iterating_reasoning < MAX_ITERATIONS_REASONING:
iterating_reasoning += 1
# 1. Create Plan
logger.info("ReActAgent: Creating plan...")
plan_stream = self._create_plan(query, docs_together, log_context)
current_plan_parts = []
yield {"thought": f"Reasoning... (iteration {iterating_reasoning})\n\n"}
for line_chunk in plan_stream:
current_plan_parts.append(line_chunk)
yield {"thought": line_chunk}
self.plan = "".join(current_plan_parts)
if self.plan:
self.observations.append(f"Plan: {self.plan} Iteration: {iterating_reasoning}")
for iteration in range(1, MAX_ITERATIONS_REASONING + 1):
yield {"thought": f"Reasoning... (iteration {iteration})\n\n"}
yield from self._planning_phase(query, log_context)
max_obs_len = 20000
obs_str = "\n".join(self.observations)
if len(obs_str) > max_obs_len:
obs_str = obs_str[:max_obs_len] + "\n...[observations truncated]"
execution_prompt_str = (
(self.prompt or "")
+ f"\n\nFollow this plan:\n{self.plan}"
+ f"\n\nObservations:\n{obs_str}"
+ f"\n\nIf there is enough data to complete user query '{query}', Respond with 'SATISFIED' only. Otherwise, continue. Dont Menstion 'SATISFIED' in your response if you are not ready. "
)
messages = self._build_messages(execution_prompt_str, query, retrieved_data)
resp_from_llm_gen = self._llm_gen(messages, log_context)
initial_llm_thought_content = self._extract_content_from_llm_response(resp_from_llm_gen)
if initial_llm_thought_content:
self.observations.append(f"Initial thought/response: {initial_llm_thought_content}")
else:
logger.info("ReActAgent: Initial LLM response (before handler) had no textual content (might be only tool calls).")
resp_after_handler = self._llm_handler(resp_from_llm_gen, tools_dict, messages, log_context)
for tool_call_info in self.tool_calls: # Iterate over self.tool_calls populated by _llm_handler
observation_string = (
f"Executed Action: Tool '{tool_call_info.get('tool_name', 'N/A')}' "
f"with arguments '{tool_call_info.get('arguments', '{}')}'. Result: '{str(tool_call_info.get('result', ''))[:200]}...'"
if not self.plan:
logger.warning(
f"ReActAgent: No plan generated in iteration {iteration}"
)
self.observations.append(observation_string)
content_after_handler = self._extract_content_from_llm_response(resp_after_handler)
if content_after_handler:
self.observations.append(f"Response after tool execution: {content_after_handler}")
else:
logger.info("ReActAgent: LLM response after handler had no textual content.")
if log_context:
log_context.stacks.append(
{"component": "agent_tool_calls", "data": {"tool_calls": self.tool_calls.copy()}}
)
yield {"sources": retrieved_data}
display_tool_calls = []
for tc in self.tool_calls:
cleaned_tc = tc.copy()
if len(str(cleaned_tc.get("result", ""))) > 50:
cleaned_tc["result"] = str(cleaned_tc["result"])[:50] + "..."
display_tool_calls.append(cleaned_tc)
if display_tool_calls:
yield {"tool_calls": display_tool_calls}
if "SATISFIED" in content_after_handler:
logger.info("ReActAgent: LLM satisfied with the plan and data. Stopping reasoning.")
break
self.observations.append(f"Plan (iteration {iteration}): {self.plan}")
# 3. Create Final Answer based on all observations
final_answer_stream = self._create_final_answer(query, self.observations, log_context)
for answer_chunk in final_answer_stream:
yield {"answer": answer_chunk}
logger.info("ReActAgent: Finished generating final answer.")
satisfied = yield from self._execution_phase(query, tools_dict, log_context)
def _create_plan(
self, query: str, docs_data: str, log_context: LogContext = None
) -> Generator[str, None, None]:
plan_prompt_filled = planning_prompt_template.replace("{query}", query)
if "{summaries}" in plan_prompt_filled:
summaries = docs_data if docs_data else "No documents retrieved."
plan_prompt_filled = plan_prompt_filled.replace("{summaries}", summaries)
plan_prompt_filled = plan_prompt_filled.replace("{prompt}", self.prompt or "")
plan_prompt_filled = plan_prompt_filled.replace("{observations}", "\n".join(self.observations))
if satisfied:
logger.info("ReActAgent: Goal satisfied, stopping reasoning loop")
break
yield from self._synthesis_phase(query, log_context)
messages = [{"role": "user", "content": plan_prompt_filled}]
def _reset_state(self):
"""Reset agent state for new query"""
self.plan = ""
self.observations = []
plan_stream_from_llm = self.llm.gen_stream(
model=self.gpt_model, messages=messages, tools=getattr(self, 'tools', None) # Use self.tools
def _planning_phase(
self, query: str, log_context: LogContext
) -> Generator[Dict, None, None]:
"""Generate strategic plan for query"""
logger.info("ReActAgent: Creating plan...")
plan_prompt = self._build_planning_prompt(query)
messages = [{"role": "user", "content": plan_prompt}]
plan_stream = self.llm.gen_stream(
model=self.model_id,
messages=messages,
tools=self.tools if self.tools else None,
)
if log_context:
data = build_stack_data(self.llm)
log_context.stacks.append({"component": "planning_llm", "data": data})
log_context.stacks.append(
{"component": "planning_llm", "data": build_stack_data(self.llm)}
)
plan_parts = []
for chunk in plan_stream:
content = self._extract_content(chunk)
if content:
plan_parts.append(content)
yield {"thought": content}
self.plan = "".join(plan_parts)
for chunk in plan_stream_from_llm:
content_piece = self._extract_content_from_llm_response(chunk)
if content_piece:
yield content_piece
def _execution_phase(
self, query: str, tools_dict: Dict, log_context: LogContext
) -> Generator[bool, None, None]:
"""Execute plan with tool calls and observations"""
execution_prompt = self._build_execution_prompt(query)
messages = self._build_messages(execution_prompt, query)
def _create_final_answer(
self, query: str, observations: List[str], log_context: LogContext = None
) -> Generator[str, None, None]:
observation_string = "\n".join(observations)
max_obs_len = 10000
if len(observation_string) > max_obs_len:
observation_string = observation_string[:max_obs_len] + "\n...[observations truncated]"
logger.warning("ReActAgent: Truncated observations for final answer prompt due to length.")
llm_response = self._llm_gen(messages, log_context)
initial_content = self._extract_content(llm_response)
final_answer_prompt_filled = final_prompt_template.format(
query=query, observations=observation_string
if initial_content:
self.observations.append(f"Initial response: {initial_content}")
processed_response = self._llm_handler(
llm_response, tools_dict, messages, log_context
)
messages = [{"role": "user", "content": final_answer_prompt_filled}]
# Final answer should synthesize, not call tools.
final_answer_stream_from_llm = self.llm.gen_stream(
model=self.gpt_model, messages=messages, tools=None
)
for tool_call in self.tool_calls:
observation = (
f"Executed: {tool_call.get('tool_name', 'Unknown')} "
f"with args {tool_call.get('arguments', {})}. "
f"Result: {str(tool_call.get('result', ''))[:200]}"
)
self.observations.append(observation)
final_content = self._extract_content(processed_response)
if final_content:
self.observations.append(f"Response after tools: {final_content}")
if log_context:
data = build_stack_data(self.llm)
log_context.stacks.append({"component": "final_answer_llm", "data": data})
log_context.stacks.append(
{
"component": "agent_tool_calls",
"data": {"tool_calls": self.tool_calls.copy()},
}
)
yield {"sources": self.retrieved_docs}
yield {"tool_calls": self._get_truncated_tool_calls()}
for chunk in final_answer_stream_from_llm:
content_piece = self._extract_content_from_llm_response(chunk)
if content_piece:
yield content_piece
return "SATISFIED" in (final_content or "")
def _synthesis_phase(
self, query: str, log_context: LogContext
) -> Generator[Dict, None, None]:
"""Synthesize final answer from all observations"""
logger.info("ReActAgent: Generating final answer...")
final_prompt = self._build_final_answer_prompt(query)
messages = [{"role": "user", "content": final_prompt}]
final_stream = self.llm.gen_stream(
model=self.model_id, messages=messages, tools=None
)
if log_context:
log_context.stacks.append(
{"component": "final_answer_llm", "data": build_stack_data(self.llm)}
)
for chunk in final_stream:
content = self._extract_content(chunk)
if content:
yield {"answer": content}
def _build_planning_prompt(self, query: str) -> str:
"""Build planning phase prompt"""
prompt = PLANNING_PROMPT_TEMPLATE.replace("{query}", query)
prompt = prompt.replace("{prompt}", self.prompt or "")
prompt = prompt.replace("{summaries}", "")
prompt = prompt.replace("{observations}", "\n".join(self.observations))
return prompt
def _build_execution_prompt(self, query: str) -> str:
"""Build execution phase prompt with plan and observations"""
observations_str = "\n".join(self.observations)
if len(observations_str) > 20000:
observations_str = observations_str[:20000] + "\n...[truncated]"
return (
f"{self.prompt or ''}\n\n"
f"Follow this plan:\n{self.plan}\n\n"
f"Observations:\n{observations_str}\n\n"
f"If sufficient data exists to answer '{query}', respond with 'SATISFIED'. "
f"Otherwise, continue executing the plan."
)
def _build_final_answer_prompt(self, query: str) -> str:
"""Build final synthesis prompt"""
observations_str = "\n".join(self.observations)
if len(observations_str) > 10000:
observations_str = observations_str[:10000] + "\n...[truncated]"
logger.warning("ReActAgent: Observations truncated for final answer")
return FINAL_PROMPT_TEMPLATE.format(query=query, observations=observations_str)
def _extract_content(self, response: Any) -> str:
"""Extract text content from various LLM response formats"""
if not response:
return ""
collected = []
if isinstance(response, str):
return response
if hasattr(response, "message") and hasattr(response.message, "content"):
if response.message.content:
return response.message.content
if hasattr(response, "choices") and response.choices:
if hasattr(response.choices[0], "message"):
content = response.choices[0].message.content
if content:
return content
if hasattr(response, "content") and isinstance(response.content, list):
if response.content and hasattr(response.content[0], "text"):
return response.content[0].text
try:
for chunk in response:
content_piece = ""
if hasattr(chunk, "choices") and chunk.choices:
if hasattr(chunk.choices[0], "delta"):
delta_content = chunk.choices[0].delta.content
if delta_content:
content_piece = delta_content
elif hasattr(chunk, "type") and chunk.type == "content_block_delta":
if hasattr(chunk, "delta") and hasattr(chunk.delta, "text"):
content_piece = chunk.delta.text
elif isinstance(chunk, str):
content_piece = chunk
if content_piece:
collected.append(content_piece)
except (TypeError, AttributeError):
logger.debug(
f"Response not iterable or unexpected format: {type(response)}"
)
except Exception as e:
logger.error(f"Error extracting content: {e}")
return "".join(collected)

View File

@@ -104,7 +104,7 @@ class MemoryTool(Tool):
"properties": {
"path": {
"type": "string",
"description": "Path to file or directory (e.g., /notes.txt or /project/)."
"description": "Path to file or directory (e.g., /notes.txt or /project/ or /)."
},
"view_range": {
"type": "array",
@@ -233,6 +233,9 @@ class MemoryTool(Tool):
# Remove any leading/trailing whitespace
path = path.strip()
# Preserve whether path ends with / (indicates directory)
is_directory = path.endswith("/")
# Ensure path starts with / for consistency
if not path.startswith("/"):
path = "/" + path
@@ -250,6 +253,10 @@ class MemoryTool(Tool):
if not normalized.startswith("/"):
return None
# Preserve trailing slash for directories
if is_directory and not normalized.endswith("/") and normalized != "/":
normalized = normalized + "/"
return normalized
except Exception:
return None
@@ -322,8 +329,8 @@ class MemoryTool(Tool):
return f"Error: Line range out of bounds. File has {len(lines)} lines."
selected_lines = lines[start_idx:end_idx]
# Add line numbers
numbered_lines = [f"{i+start}: {line}" for i, line in enumerate(selected_lines, start=start_idx)]
# Add line numbers (enumerate with 1-based start)
numbered_lines = [f"{i}: {line}" for i, line in enumerate(selected_lines, start=start)]
return "\n".join(numbered_lines)
return content
@@ -480,6 +487,10 @@ class MemoryTool(Tool):
# Check if renaming a directory
if validated_old.endswith("/"):
# Ensure validated_new also ends with / for proper path replacement
if not validated_new.endswith("/"):
validated_new = validated_new + "/"
# Find all files in the old directory
docs = list(self.collection.find({
"user_id": self.user_id,

View File

@@ -1,5 +1,6 @@
from datetime import datetime
from typing import Any, Dict, List, Optional
import uuid
from .base import Tool
from application.core.mongo_db import MongoDB
@@ -16,13 +17,24 @@ class NotesTool(Tool):
"""Initialize the tool.
Args:
tool_config: Optional tool configuration (unused for now).
tool_config: Optional tool configuration. Should include:
- tool_id: Unique identifier for this notes tool instance (from user_tools._id)
This ensures each user's tool configuration has isolated notes
user_id: The authenticated user's id (should come from decoded_token["sub"]).
"""
self.user_id: Optional[str] = user_id
# Get tool_id from configuration (passed from user_tools._id in production)
# In production, tool_id is the MongoDB ObjectId string from user_tools collection
if tool_config and "tool_id" in tool_config:
self.tool_id = tool_config["tool_id"]
elif user_id:
# Fallback for backward compatibility or testing
self.tool_id = f"default_{user_id}"
else:
# Last resort fallback (shouldn't happen in normal use)
self.tool_id = str(uuid.uuid4())
db = MongoDB.get_client()[settings.MONGO_DB_NAME]
self.collection = db["notes"]
@@ -117,7 +129,7 @@ class NotesTool(Tool):
# Internal helpers (single-note)
# -----------------------------
def _get_note(self) -> str:
doc = self.collection.find_one({"user_id": self.user_id})
doc = self.collection.find_one({"user_id": self.user_id, "tool_id": self.tool_id})
if not doc or not doc.get("note"):
return "No note found."
return str(doc["note"])
@@ -127,7 +139,7 @@ class NotesTool(Tool):
if not content:
return "Note content required."
self.collection.update_one(
{"user_id": self.user_id},
{"user_id": self.user_id, "tool_id": self.tool_id},
{"$set": {"note": content, "updated_at": datetime.utcnow()}},
upsert=True, # ✅ create if missing
)
@@ -137,7 +149,7 @@ class NotesTool(Tool):
if not old_str:
return "old_str is required."
doc = self.collection.find_one({"user_id": self.user_id})
doc = self.collection.find_one({"user_id": self.user_id, "tool_id": self.tool_id})
if not doc or not doc.get("note"):
return "No note found."
@@ -152,7 +164,7 @@ class NotesTool(Tool):
updated_note = re.sub(re.escape(old_str), new_str, current_note, flags=re.IGNORECASE)
self.collection.update_one(
{"user_id": self.user_id},
{"user_id": self.user_id, "tool_id": self.tool_id},
{"$set": {"note": updated_note, "updated_at": datetime.utcnow()}},
)
return "Note updated."
@@ -161,7 +173,7 @@ class NotesTool(Tool):
if not text:
return "Text is required."
doc = self.collection.find_one({"user_id": self.user_id})
doc = self.collection.find_one({"user_id": self.user_id, "tool_id": self.tool_id})
if not doc or not doc.get("note"):
return "No note found."
@@ -177,11 +189,11 @@ class NotesTool(Tool):
updated_note = "\n".join(lines)
self.collection.update_one(
{"user_id": self.user_id},
{"user_id": self.user_id, "tool_id": self.tool_id},
{"$set": {"note": updated_note, "updated_at": datetime.utcnow()}},
)
return "Text inserted."
def _delete_note(self) -> str:
res = self.collection.delete_one({"user_id": self.user_id})
res = self.collection.delete_one({"user_id": self.user_id, "tool_id": self.tool_id})
return "Note deleted." if res.deleted_count else "No note found to delete."

View File

@@ -0,0 +1,321 @@
from datetime import datetime
from typing import Any, Dict, List, Optional
import uuid
from .base import Tool
from application.core.mongo_db import MongoDB
from application.core.settings import settings
class TodoListTool(Tool):
"""Todo List
Manages todo items for users. Supports creating, viewing, updating, and deleting todos.
"""
def __init__(self, tool_config: Optional[Dict[str, Any]] = None, user_id: Optional[str] = None) -> None:
"""Initialize the tool.
Args:
tool_config: Optional tool configuration. Should include:
- tool_id: Unique identifier for this todo list tool instance (from user_tools._id)
This ensures each user's tool configuration has isolated todos
user_id: The authenticated user's id (should come from decoded_token["sub"]).
"""
self.user_id: Optional[str] = user_id
# Get tool_id from configuration (passed from user_tools._id in production)
# In production, tool_id is the MongoDB ObjectId string from user_tools collection
if tool_config and "tool_id" in tool_config:
self.tool_id = tool_config["tool_id"]
elif user_id:
# Fallback for backward compatibility or testing
self.tool_id = f"default_{user_id}"
else:
# Last resort fallback (shouldn't happen in normal use)
self.tool_id = str(uuid.uuid4())
db = MongoDB.get_client()[settings.MONGO_DB_NAME]
self.collection = db["todos"]
# -----------------------------
# Action implementations
# -----------------------------
def execute_action(self, action_name: str, **kwargs: Any) -> str:
"""Execute an action by name.
Args:
action_name: One of list, create, get, update, complete, delete.
**kwargs: Parameters for the action.
Returns:
A human-readable string result.
"""
if not self.user_id:
return "Error: TodoListTool requires a valid user_id."
if action_name == "list":
return self._list()
if action_name == "create":
return self._create(kwargs.get("title", ""))
if action_name == "get":
return self._get(kwargs.get("todo_id"))
if action_name == "update":
return self._update(
kwargs.get("todo_id"),
kwargs.get("title", "")
)
if action_name == "complete":
return self._complete(kwargs.get("todo_id"))
if action_name == "delete":
return self._delete(kwargs.get("todo_id"))
return f"Unknown action: {action_name}"
def get_actions_metadata(self) -> List[Dict[str, Any]]:
"""Return JSON metadata describing supported actions for tool schemas."""
return [
{
"name": "list",
"description": "List all todos for the user.",
"parameters": {"type": "object", "properties": {}},
},
{
"name": "create",
"description": "Create a new todo item.",
"parameters": {
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "Title of the todo item."
}
},
"required": ["title"],
},
},
{
"name": "get",
"description": "Get a specific todo by ID.",
"parameters": {
"type": "object",
"properties": {
"todo_id": {
"type": "integer",
"description": "The ID of the todo to retrieve."
}
},
"required": ["todo_id"],
},
},
{
"name": "update",
"description": "Update a todo's title by ID.",
"parameters": {
"type": "object",
"properties": {
"todo_id": {
"type": "integer",
"description": "The ID of the todo to update."
},
"title": {
"type": "string",
"description": "The new title for the todo."
}
},
"required": ["todo_id", "title"],
},
},
{
"name": "complete",
"description": "Mark a todo as completed.",
"parameters": {
"type": "object",
"properties": {
"todo_id": {
"type": "integer",
"description": "The ID of the todo to mark as completed."
}
},
"required": ["todo_id"],
},
},
{
"name": "delete",
"description": "Delete a specific todo by ID.",
"parameters": {
"type": "object",
"properties": {
"todo_id": {
"type": "integer",
"description": "The ID of the todo to delete."
}
},
"required": ["todo_id"],
},
},
]
def get_config_requirements(self) -> Dict[str, Any]:
"""Return configuration requirements."""
return {}
# -----------------------------
# Internal helpers
# -----------------------------
def _coerce_todo_id(self, value: Optional[Any]) -> Optional[int]:
"""Convert todo identifiers to sequential integers."""
if value is None:
return None
if isinstance(value, int):
return value if value > 0 else None
if isinstance(value, str):
stripped = value.strip()
if stripped.isdigit():
numeric_value = int(stripped)
return numeric_value if numeric_value > 0 else None
return None
def _get_next_todo_id(self) -> int:
"""Get the next sequential todo_id for this user and tool.
Returns a simple integer (1, 2, 3, ...) scoped to this user/tool.
With 5-10 todos max, scanning is negligible.
"""
# Find all todos for this user/tool and get their IDs
todos = list(self.collection.find(
{"user_id": self.user_id, "tool_id": self.tool_id},
{"todo_id": 1}
))
# Find the maximum todo_id
max_id = 0
for todo in todos:
todo_id = self._coerce_todo_id(todo.get("todo_id"))
if todo_id is not None:
max_id = max(max_id, todo_id)
return max_id + 1
def _list(self) -> str:
"""List all todos for the user."""
cursor = self.collection.find({"user_id": self.user_id, "tool_id": self.tool_id})
todos = list(cursor)
if not todos:
return "No todos found."
result_lines = ["Todos:"]
for doc in todos:
todo_id = doc.get("todo_id")
title = doc.get("title", "Untitled")
status = doc.get("status", "open")
line = f"[{todo_id}] {title} ({status})"
result_lines.append(line)
return "\n".join(result_lines)
def _create(self, title: str) -> str:
"""Create a new todo item."""
title = (title or "").strip()
if not title:
return "Error: Title is required."
now = datetime.now()
todo_id = self._get_next_todo_id()
doc = {
"todo_id": todo_id,
"user_id": self.user_id,
"tool_id": self.tool_id,
"title": title,
"status": "open",
"created_at": now,
"updated_at": now,
}
self.collection.insert_one(doc)
return f"Todo created with ID {todo_id}: {title}"
def _get(self, todo_id: Optional[Any]) -> str:
"""Get a specific todo by ID."""
parsed_todo_id = self._coerce_todo_id(todo_id)
if parsed_todo_id is None:
return "Error: todo_id must be a positive integer."
doc = self.collection.find_one({
"user_id": self.user_id,
"tool_id": self.tool_id,
"todo_id": parsed_todo_id
})
if not doc:
return f"Error: Todo with ID {parsed_todo_id} not found."
title = doc.get("title", "Untitled")
status = doc.get("status", "open")
result = f"Todo [{parsed_todo_id}]:\nTitle: {title}\nStatus: {status}"
return result
def _update(self, todo_id: Optional[Any], title: str) -> str:
"""Update a todo's title by ID."""
parsed_todo_id = self._coerce_todo_id(todo_id)
if parsed_todo_id is None:
return "Error: todo_id must be a positive integer."
title = (title or "").strip()
if not title:
return "Error: Title is required."
result = self.collection.update_one(
{"user_id": self.user_id, "tool_id": self.tool_id, "todo_id": parsed_todo_id},
{"$set": {"title": title, "updated_at": datetime.now()}}
)
if result.matched_count == 0:
return f"Error: Todo with ID {parsed_todo_id} not found."
return f"Todo {parsed_todo_id} updated to: {title}"
def _complete(self, todo_id: Optional[Any]) -> str:
"""Mark a todo as completed."""
parsed_todo_id = self._coerce_todo_id(todo_id)
if parsed_todo_id is None:
return "Error: todo_id must be a positive integer."
result = self.collection.update_one(
{"user_id": self.user_id, "tool_id": self.tool_id, "todo_id": parsed_todo_id},
{"$set": {"status": "completed", "updated_at": datetime.now()}}
)
if result.matched_count == 0:
return f"Error: Todo with ID {parsed_todo_id} not found."
return f"Todo {parsed_todo_id} marked as completed."
def _delete(self, todo_id: Optional[Any]) -> str:
"""Delete a specific todo by ID."""
parsed_todo_id = self._coerce_todo_id(todo_id)
if parsed_todo_id is None:
return "Error: todo_id must be a positive integer."
result = self.collection.delete_one({
"user_id": self.user_id,
"tool_id": self.tool_id,
"todo_id": parsed_todo_id
})
if result.deleted_count == 0:
return f"Error: Todo with ID {parsed_todo_id} not found."
return f"Todo {parsed_todo_id} deleted."

View File

@@ -20,20 +20,24 @@ class ToolActionParser:
try:
call_args = json.loads(call.arguments)
tool_parts = call.name.split("_")
# If the tool name doesn't contain an underscore, it's likely a hallucinated tool
if len(tool_parts) < 2:
logger.warning(f"Invalid tool name format: {call.name}. Expected format: action_name_tool_id")
logger.warning(
f"Invalid tool name format: {call.name}. Expected format: action_name_tool_id"
)
return None, None, None
tool_id = tool_parts[-1]
action_name = "_".join(tool_parts[:-1])
# Validate that tool_id looks like a numerical ID
if not tool_id.isdigit():
logger.warning(f"Tool ID '{tool_id}' is not numerical. This might be a hallucinated tool call.")
except (AttributeError, TypeError) as e:
logger.warning(
f"Tool ID '{tool_id}' is not numerical. This might be a hallucinated tool call."
)
except (AttributeError, TypeError, json.JSONDecodeError) as e:
logger.error(f"Error parsing OpenAI LLM call: {e}")
return None, None, None
return tool_id, action_name, call_args
@@ -42,19 +46,23 @@ class ToolActionParser:
try:
call_args = call.arguments
tool_parts = call.name.split("_")
# If the tool name doesn't contain an underscore, it's likely a hallucinated tool
if len(tool_parts) < 2:
logger.warning(f"Invalid tool name format: {call.name}. Expected format: action_name_tool_id")
logger.warning(
f"Invalid tool name format: {call.name}. Expected format: action_name_tool_id"
)
return None, None, None
tool_id = tool_parts[-1]
action_name = "_".join(tool_parts[:-1])
# Validate that tool_id looks like a numerical ID
if not tool_id.isdigit():
logger.warning(f"Tool ID '{tool_id}' is not numerical. This might be a hallucinated tool call.")
logger.warning(
f"Tool ID '{tool_id}' is not numerical. This might be a hallucinated tool call."
)
except (AttributeError, TypeError) as e:
logger.error(f"Error parsing Google LLM call: {e}")
return None, None, None

View File

@@ -28,7 +28,7 @@ class ToolManager:
module = importlib.import_module(f"application.agents.tools.{tool_name}")
for member_name, obj in inspect.getmembers(module, inspect.isclass):
if issubclass(obj, Tool) and obj is not Tool:
if tool_name in {"mcp_tool", "notes", "memory"} and user_id:
if tool_name in {"mcp_tool", "notes", "memory", "todo_list"} and user_id:
return obj(tool_config, user_id)
else:
return obj(tool_config)
@@ -36,7 +36,7 @@ class ToolManager:
def execute_action(self, tool_name, action_name, user_id=None, **kwargs):
if tool_name not in self.tools:
raise ValueError(f"Tool '{tool_name}' not loaded")
if tool_name in {"mcp_tool", "memory"} and user_id:
if tool_name in {"mcp_tool", "memory", "todo_list"} and user_id:
tool_config = self.config.get(tool_name, {})
tool = self.load_tool(tool_name, tool_config, user_id)
return tool.execute_action(action_name, **kwargs)

View File

@@ -54,6 +54,14 @@ class AnswerResource(Resource, BaseAnswerResource):
default=True,
description="Whether to save the conversation",
),
"model_id": fields.String(
required=False,
description="Model ID to use for this request",
),
"passthrough": fields.Raw(
required=False,
description="Dynamic parameters to inject into prompt template",
),
},
)
@@ -69,19 +77,31 @@ class AnswerResource(Resource, BaseAnswerResource):
processor.initialize()
if not processor.decoded_token:
return make_response({"error": "Unauthorized"}, 401)
agent = processor.create_agent()
retriever = processor.create_retriever()
docs_together, docs_list = processor.pre_fetch_docs(
data.get("question", "")
)
tools_data = processor.pre_fetch_tools()
agent = processor.create_agent(
docs_together=docs_together,
docs=docs_list,
tools_data=tools_data,
)
if error := self.check_usage(processor.agent_config):
return error
stream = self.complete_stream(
question=data["question"],
agent=agent,
retriever=retriever,
conversation_id=processor.conversation_id,
user_api_key=processor.agent_config.get("user_api_key"),
decoded_token=processor.decoded_token,
isNoneDoc=data.get("isNoneDoc"),
index=None,
should_save_conversation=data.get("save_conversation", True),
model_id=processor.model_id,
)
stream_result = self.process_response_stream(stream)

View File

@@ -3,15 +3,20 @@ import json
import logging
from typing import Any, Dict, Generator, List, Optional
from flask import Response
from flask import jsonify, make_response, Response
from flask_restx import Namespace
from application.api.answer.services.conversation_service import ConversationService
from application.core.model_utils import (
get_api_key_for_provider,
get_default_model_id,
get_provider_from_model_id,
)
from application.core.mongo_db import MongoDB
from application.core.settings import settings
from application.llm.llm_creator import LLMCreator
from application.utils import check_required_fields, get_gpt_model
from application.utils import check_required_fields
logger = logging.getLogger(__name__)
@@ -25,8 +30,9 @@ class BaseAnswerResource:
def __init__(self):
mongo = MongoDB.get_client()
db = mongo[settings.MONGO_DB_NAME]
self.db = db
self.user_logs_collection = db["user_logs"]
self.gpt_model = get_gpt_model()
self.default_model_id = get_default_model_id()
self.conversation_service = ConversationService()
def validate_request(
@@ -40,11 +46,104 @@ class BaseAnswerResource:
return missing_fields
return None
def check_usage(self, agent_config: Dict) -> Optional[Response]:
"""Check if there is a usage limit and if it is exceeded
Args:
agent_config: The config dict of agent instance
Returns:
None or Response if either of limits exceeded.
"""
api_key = agent_config.get("user_api_key")
if not api_key:
return None
agents_collection = self.db["agents"]
agent = agents_collection.find_one({"key": api_key})
if not agent:
return make_response(
jsonify({"success": False, "message": "Invalid API key."}), 401
)
limited_token_mode_raw = agent.get("limited_token_mode", False)
limited_request_mode_raw = agent.get("limited_request_mode", False)
limited_token_mode = (
limited_token_mode_raw
if isinstance(limited_token_mode_raw, bool)
else limited_token_mode_raw == "True"
)
limited_request_mode = (
limited_request_mode_raw
if isinstance(limited_request_mode_raw, bool)
else limited_request_mode_raw == "True"
)
token_limit = int(
agent.get("token_limit", settings.DEFAULT_AGENT_LIMITS["token_limit"])
)
request_limit = int(
agent.get("request_limit", settings.DEFAULT_AGENT_LIMITS["request_limit"])
)
token_usage_collection = self.db["token_usage"]
end_date = datetime.datetime.now()
start_date = end_date - datetime.timedelta(hours=24)
match_query = {
"timestamp": {"$gte": start_date, "$lte": end_date},
"api_key": api_key,
}
if limited_token_mode:
token_pipeline = [
{"$match": match_query},
{
"$group": {
"_id": None,
"total_tokens": {
"$sum": {"$add": ["$prompt_tokens", "$generated_tokens"]}
},
}
},
]
token_result = list(token_usage_collection.aggregate(token_pipeline))
daily_token_usage = token_result[0]["total_tokens"] if token_result else 0
else:
daily_token_usage = 0
if limited_request_mode:
daily_request_usage = token_usage_collection.count_documents(match_query)
else:
daily_request_usage = 0
if not limited_token_mode and not limited_request_mode:
return None
token_exceeded = (
limited_token_mode and token_limit > 0 and daily_token_usage >= token_limit
)
request_exceeded = (
limited_request_mode
and request_limit > 0
and daily_request_usage >= request_limit
)
if token_exceeded or request_exceeded:
return make_response(
jsonify(
{
"success": False,
"message": "Exceeding usage limit, please try again later.",
}
),
429,
)
return None
def complete_stream(
self,
question: str,
agent: Any,
retriever: Any,
conversation_id: Optional[str],
user_api_key: Optional[str],
decoded_token: Dict[str, Any],
@@ -55,6 +154,7 @@ class BaseAnswerResource:
agent_id: Optional[str] = None,
is_shared_usage: bool = False,
shared_token: Optional[str] = None,
model_id: Optional[str] = None,
) -> Generator[str, None, None]:
"""
Generator function that streams the complete conversation response.
@@ -73,6 +173,8 @@ class BaseAnswerResource:
agent_id: ID of agent used
is_shared_usage: Flag for shared agent usage
shared_token: Token for shared agent
model_id: Model ID used for the request
retrieved_docs: Pre-fetched documents for sources (optional)
Yields:
Server-sent event strings
@@ -83,7 +185,7 @@ class BaseAnswerResource:
schema_info = None
structured_chunks = []
for line in agent.gen(query=question, retriever=retriever):
for line in agent.gen(query=question):
if "answer" in line:
response_full += str(line["answer"])
if line.get("structured"):
@@ -119,7 +221,6 @@ class BaseAnswerResource:
elif "type" in line:
data = json.dumps(line)
yield f"data: {data}\n\n"
if is_structured and structured_chunks:
structured_data = {
"type": "structured_answer",
@@ -129,15 +230,22 @@ class BaseAnswerResource:
}
data = json.dumps(structured_data)
yield f"data: {data}\n\n"
if isNoneDoc:
for doc in source_log_docs:
doc["source"] = "None"
provider = (
get_provider_from_model_id(model_id)
if model_id
else settings.LLM_PROVIDER
)
system_api_key = get_api_key_for_provider(provider or settings.LLM_PROVIDER)
llm = LLMCreator.create_llm(
settings.LLM_PROVIDER,
api_key=settings.API_KEY,
provider or settings.LLM_PROVIDER,
api_key=system_api_key,
user_api_key=user_api_key,
decoded_token=decoded_token,
model_id=model_id,
)
if should_save_conversation:
@@ -149,7 +257,7 @@ class BaseAnswerResource:
source_log_docs,
tool_calls,
llm,
self.gpt_model,
model_id or self.default_model_id,
decoded_token,
index=index,
api_key=user_api_key,
@@ -164,7 +272,6 @@ class BaseAnswerResource:
data = json.dumps(id_data)
yield f"data: {data}\n\n"
retriever_params = retriever.get_params()
log_data = {
"action": "stream_answer",
"level": "info",
@@ -173,7 +280,6 @@ class BaseAnswerResource:
"question": question,
"response": response_full,
"sources": source_log_docs,
"retriever_params": retriever_params,
"attachments": attachment_ids,
"timestamp": datetime.datetime.now(datetime.timezone.utc),
}
@@ -181,18 +287,52 @@ class BaseAnswerResource:
log_data["structured_output"] = True
if schema_info:
log_data["schema"] = schema_info
# clean up text fields to be no longer than 10000 characters
# Clean up text fields to be no longer than 10000 characters
for key, value in log_data.items():
if isinstance(value, str) and len(value) > 10000:
log_data[key] = value[:10000]
self.user_logs_collection.insert_one(log_data)
# End of stream
data = json.dumps({"type": "end"})
yield f"data: {data}\n\n"
except GeneratorExit:
logger.info(f"Stream aborted by client for question: {question[:50]}... ")
# Save partial response
if should_save_conversation and response_full:
try:
if isNoneDoc:
for doc in source_log_docs:
doc["source"] = "None"
llm = LLMCreator.create_llm(
settings.LLM_PROVIDER,
api_key=settings.API_KEY,
user_api_key=user_api_key,
decoded_token=decoded_token,
)
self.conversation_service.save_conversation(
conversation_id,
question,
response_full,
thought,
source_log_docs,
tool_calls,
llm,
model_id or self.default_model_id,
decoded_token,
index=index,
api_key=user_api_key,
agent_id=agent_id,
is_shared_usage=is_shared_usage,
shared_token=shared_token,
attachment_ids=attachment_ids,
)
except Exception as e:
logger.error(
f"Error saving partial response: {str(e)}", exc_info=True
)
raise
except Exception as e:
logger.error(f"Error in stream: {str(e)}", exc_info=True)
data = json.dumps(
@@ -236,7 +376,7 @@ class BaseAnswerResource:
thought = event["thought"]
elif event["type"] == "error":
logger.error(f"Error from stream: {event['error']}")
return None, None, None, None, event["error"]
return None, None, None, None, event["error"], None
elif event["type"] == "end":
stream_ended = True
except (json.JSONDecodeError, KeyError) as e:
@@ -244,8 +384,7 @@ class BaseAnswerResource:
continue
if not stream_ended:
logger.error("Stream ended unexpectedly without an 'end' event.")
return None, None, None, None, "Stream ended unexpectedly"
return None, None, None, None, "Stream ended unexpectedly", None
result = (
conversation_id,
response_full,
@@ -257,7 +396,6 @@ class BaseAnswerResource:
if is_structured:
result = result + ({"structured": True, "schema": schema_info},)
return result
def error_stream_generate(self, err_response):

View File

@@ -57,9 +57,17 @@ class StreamResource(Resource, BaseAnswerResource):
default=True,
description="Whether to save the conversation",
),
"model_id": fields.String(
required=False,
description="Model ID to use for this request",
),
"attachments": fields.List(
fields.String, required=False, description="List of attachment IDs"
),
"passthrough": fields.Raw(
required=False,
description="Dynamic parameters to inject into prompt template",
),
},
)
@@ -73,14 +81,20 @@ class StreamResource(Resource, BaseAnswerResource):
processor = StreamProcessor(data, decoded_token)
try:
processor.initialize()
agent = processor.create_agent()
retriever = processor.create_retriever()
docs_together, docs_list = processor.pre_fetch_docs(data["question"])
tools_data = processor.pre_fetch_tools()
agent = processor.create_agent(
docs_together=docs_together, docs=docs_list, tools_data=tools_data
)
if error := self.check_usage(processor.agent_config):
return error
return Response(
self.complete_stream(
question=data["question"],
agent=agent,
retriever=retriever,
conversation_id=processor.conversation_id,
user_api_key=processor.agent_config.get("user_api_key"),
decoded_token=processor.decoded_token,
@@ -91,6 +105,7 @@ class StreamResource(Resource, BaseAnswerResource):
agent_id=data.get("agent_id"),
is_shared_usage=processor.is_shared_usage,
shared_token=processor.shared_token,
model_id=processor.model_id,
),
mimetype="text/event-stream",
)

View File

@@ -52,7 +52,7 @@ class ConversationService:
sources: List[Dict[str, Any]],
tool_calls: List[Dict[str, Any]],
llm: Any,
gpt_model: str,
model_id: str,
decoded_token: Dict[str, Any],
index: Optional[int] = None,
api_key: Optional[str] = None,
@@ -66,7 +66,7 @@ class ConversationService:
if not user_id:
raise ValueError("User ID not found in token")
current_time = datetime.now(timezone.utc)
# clean up in sources array such that we save max 1k characters for text part
for source in sources:
if "text" in source and isinstance(source["text"], str):
@@ -90,6 +90,7 @@ class ConversationService:
f"queries.{index}.tool_calls": tool_calls,
f"queries.{index}.timestamp": current_time,
f"queries.{index}.attachments": attachment_ids,
f"queries.{index}.model_id": model_id,
}
},
)
@@ -120,6 +121,7 @@ class ConversationService:
"tool_calls": tool_calls,
"timestamp": current_time,
"attachments": attachment_ids,
"model_id": model_id,
}
}
},
@@ -133,10 +135,9 @@ class ConversationService:
messages_summary = [
{
"role": "assistant",
"content": "Summarise following conversation in no more than 3 "
"words, respond ONLY with the summary, use the same "
"language as the user query",
"role": "system",
"content": "You are a helpful assistant that creates concise conversation titles. "
"Summarize conversations in 3 words or less using the same language as the user.",
},
{
"role": "user",
@@ -147,7 +148,7 @@ class ConversationService:
]
completion = llm.gen(
model=gpt_model, messages=messages_summary, max_tokens=30
model=model_id, messages=messages_summary, max_tokens=30
)
conversation_data = {
@@ -163,6 +164,7 @@ class ConversationService:
"tool_calls": tool_calls,
"timestamp": current_time,
"attachments": attachment_ids,
"model_id": model_id,
}
],
}

View File

@@ -0,0 +1,97 @@
import logging
from typing import Any, Dict, Optional
from application.templates.namespaces import NamespaceManager
from application.templates.template_engine import TemplateEngine, TemplateRenderError
logger = logging.getLogger(__name__)
class PromptRenderer:
"""Service for rendering prompts with dynamic context using namespaces"""
def __init__(self):
self.template_engine = TemplateEngine()
self.namespace_manager = NamespaceManager()
def render_prompt(
self,
prompt_content: str,
user_id: Optional[str] = None,
request_id: Optional[str] = None,
passthrough_data: Optional[Dict[str, Any]] = None,
docs: Optional[list] = None,
docs_together: Optional[str] = None,
tools_data: Optional[Dict[str, Any]] = None,
**kwargs,
) -> str:
"""
Render prompt with full context from all namespaces.
Args:
prompt_content: Raw prompt template string
user_id: Current user identifier
request_id: Unique request identifier
passthrough_data: Parameters from web request
docs: RAG retrieved documents
docs_together: Concatenated document content
tools_data: Pre-fetched tool results organized by tool name
**kwargs: Additional parameters for namespace builders
Returns:
Rendered prompt string with all variables substituted
Raises:
TemplateRenderError: If template rendering fails
"""
if not prompt_content:
return ""
uses_template = self._uses_template_syntax(prompt_content)
if not uses_template:
return self._apply_legacy_substitutions(prompt_content, docs_together)
try:
context = self.namespace_manager.build_context(
user_id=user_id,
request_id=request_id,
passthrough_data=passthrough_data,
docs=docs,
docs_together=docs_together,
tools_data=tools_data,
**kwargs,
)
return self.template_engine.render(prompt_content, context)
except TemplateRenderError:
raise
except Exception as e:
error_msg = f"Prompt rendering failed: {str(e)}"
logger.error(error_msg)
raise TemplateRenderError(error_msg) from e
def _uses_template_syntax(self, prompt_content: str) -> bool:
"""Check if prompt uses Jinja2 template syntax"""
return "{{" in prompt_content and "}}" in prompt_content
def _apply_legacy_substitutions(
self, prompt_content: str, docs_together: Optional[str] = None
) -> str:
"""
Apply backward-compatible substitutions for old prompt format.
Handles legacy {summaries} and {query} placeholders during transition period.
"""
if docs_together:
prompt_content = prompt_content.replace("{summaries}", docs_together)
return prompt_content
def validate_template(self, prompt_content: str) -> bool:
"""Validate prompt template syntax"""
return self.template_engine.validate_template(prompt_content)
def extract_variables(self, prompt_content: str) -> set[str]:
"""Extract all variable names from prompt template"""
return self.template_engine.extract_variables(prompt_content)

View File

@@ -3,7 +3,7 @@ import json
import logging
import os
from pathlib import Path
from typing import Any, Dict, Optional
from typing import Any, Dict, Optional, Set
from bson.dbref import DBRef
@@ -11,10 +11,20 @@ from bson.objectid import ObjectId
from application.agents.agent_creator import AgentCreator
from application.api.answer.services.conversation_service import ConversationService
from application.api.answer.services.prompt_renderer import PromptRenderer
from application.core.model_utils import (
get_api_key_for_provider,
get_default_model_id,
get_provider_from_model_id,
validate_model_id,
)
from application.core.mongo_db import MongoDB
from application.core.settings import settings
from application.retriever.retriever_creator import RetrieverCreator
from application.utils import get_gpt_model, limit_chat_history
from application.utils import (
calculate_doc_token_budget,
limit_chat_history,
)
logger = logging.getLogger(__name__)
@@ -73,15 +83,20 @@ class StreamProcessor:
self.all_sources = []
self.attachments = []
self.history = []
self.retrieved_docs = []
self.agent_config = {}
self.retriever_config = {}
self.is_shared_usage = False
self.shared_token = None
self.gpt_model = get_gpt_model()
self.model_id: Optional[str] = None
self.conversation_service = ConversationService()
self.prompt_renderer = PromptRenderer()
self._prompt_content: Optional[str] = None
self._required_tool_actions: Optional[Dict[str, Set[Optional[str]]]] = None
def initialize(self):
"""Initialize all required components for processing"""
self._validate_and_set_model()
self._configure_agent()
self._configure_source()
self._configure_retriever()
@@ -103,7 +118,7 @@ class StreamProcessor:
]
else:
self.history = limit_chat_history(
json.loads(self.data.get("history", "[]")), gpt_model=self.gpt_model
json.loads(self.data.get("history", "[]")), model_id=self.model_id
)
def _process_attachments(self):
@@ -134,6 +149,25 @@ class StreamProcessor:
)
return attachments
def _validate_and_set_model(self):
"""Validate and set model_id from request"""
from application.core.model_settings import ModelRegistry
requested_model = self.data.get("model_id")
if requested_model:
if not validate_model_id(requested_model):
registry = ModelRegistry.get_instance()
available_models = [m.id for m in registry.get_enabled_models()]
raise ValueError(
f"Invalid model_id '{requested_model}'. "
f"Available models: {', '.join(available_models[:5])}"
+ (f" and {len(available_models) - 5} more" if len(available_models) > 5 else "")
)
self.model_id = requested_model
else:
self.model_id = get_default_model_id()
def _get_agent_key(self, agent_id: Optional[str], user_id: Optional[str]) -> tuple:
"""Get API key for agent with access control"""
if not agent_id:
@@ -311,43 +345,330 @@ class StreamProcessor:
)
def _configure_retriever(self):
"""Configure the retriever based on request data"""
history_token_limit = int(self.data.get("token_limit", 2000))
doc_token_limit = calculate_doc_token_budget(
model_id=self.model_id, history_token_limit=history_token_limit
)
self.retriever_config = {
"retriever_name": self.data.get("retriever", "classic"),
"chunks": int(self.data.get("chunks", 2)),
"token_limit": self.data.get("token_limit", settings.DEFAULT_MAX_HISTORY),
"doc_token_limit": doc_token_limit,
"history_token_limit": history_token_limit,
}
api_key = self.data.get("api_key") or self.agent_key
if not api_key and "isNoneDoc" in self.data and self.data["isNoneDoc"]:
self.retriever_config["chunks"] = 0
def create_agent(self):
"""Create and return the configured agent"""
return AgentCreator.create_agent(
self.agent_config["agent_type"],
endpoint="stream",
llm_name=settings.LLM_PROVIDER,
gpt_model=self.gpt_model,
api_key=settings.API_KEY,
user_api_key=self.agent_config["user_api_key"],
prompt=get_prompt(self.agent_config["prompt_id"], self.prompts_collection),
chat_history=self.history,
decoded_token=self.decoded_token,
attachments=self.attachments,
json_schema=self.agent_config.get("json_schema"),
)
def create_retriever(self):
"""Create and return the configured retriever"""
return RetrieverCreator.create_retriever(
self.retriever_config["retriever_name"],
source=self.source,
chat_history=self.history,
prompt=get_prompt(self.agent_config["prompt_id"], self.prompts_collection),
chunks=self.retriever_config["chunks"],
token_limit=self.retriever_config["token_limit"],
gpt_model=self.gpt_model,
doc_token_limit=self.retriever_config.get("doc_token_limit", 50000),
model_id=self.model_id,
user_api_key=self.agent_config["user_api_key"],
decoded_token=self.decoded_token,
)
def pre_fetch_docs(self, question: str) -> tuple[Optional[str], Optional[list]]:
"""Pre-fetch documents for template rendering before agent creation"""
if self.data.get("isNoneDoc", False):
logger.info("Pre-fetch skipped: isNoneDoc=True")
return None, None
try:
retriever = self.create_retriever()
logger.info(
f"Pre-fetching docs with chunks={retriever.chunks}, doc_token_limit={retriever.doc_token_limit}"
)
docs = retriever.search(question)
logger.info(f"Pre-fetch retrieved {len(docs) if docs else 0} documents")
if not docs:
logger.info("Pre-fetch: No documents returned from search")
return None, None
self.retrieved_docs = docs
docs_with_filenames = []
for doc in docs:
filename = doc.get("filename") or doc.get("title") or doc.get("source")
if filename:
chunk_header = str(filename)
docs_with_filenames.append(f"{chunk_header}\n{doc['text']}")
else:
docs_with_filenames.append(doc["text"])
docs_together = "\n\n".join(docs_with_filenames)
logger.info(f"Pre-fetch docs_together size: {len(docs_together)} chars")
return docs_together, docs
except Exception as e:
logger.error(f"Failed to pre-fetch docs: {str(e)}", exc_info=True)
return None, None
def pre_fetch_tools(self) -> Optional[Dict[str, Any]]:
"""Pre-fetch tool data for template rendering before agent creation
Can be controlled via:
1. Global setting: ENABLE_TOOL_PREFETCH in .env
2. Per-request: disable_tool_prefetch in request data
"""
if not settings.ENABLE_TOOL_PREFETCH:
logger.info(
"Tool pre-fetching disabled globally via ENABLE_TOOL_PREFETCH setting"
)
return None
if self.data.get("disable_tool_prefetch", False):
logger.info("Tool pre-fetching disabled for this request")
return None
required_tool_actions = self._get_required_tool_actions()
filtering_enabled = required_tool_actions is not None
try:
user_tools_collection = self.db["user_tools"]
user_id = self.initial_user_id or "local"
user_tools = list(
user_tools_collection.find({"user": user_id, "status": True})
)
if not user_tools:
return None
tools_data = {}
for tool_doc in user_tools:
tool_name = tool_doc.get("name")
tool_id = str(tool_doc.get("_id"))
if filtering_enabled:
required_actions_by_name = required_tool_actions.get(
tool_name, set()
)
required_actions_by_id = required_tool_actions.get(tool_id, set())
required_actions = required_actions_by_name | required_actions_by_id
if not required_actions:
continue
else:
required_actions = None
tool_data = self._fetch_tool_data(tool_doc, required_actions)
if tool_data:
tools_data[tool_name] = tool_data
tools_data[tool_id] = tool_data
return tools_data if tools_data else None
except Exception as e:
logger.warning(f"Failed to pre-fetch tools: {type(e).__name__}")
return None
def _fetch_tool_data(
self,
tool_doc: Dict[str, Any],
required_actions: Optional[Set[Optional[str]]],
) -> Optional[Dict[str, Any]]:
"""Fetch and execute tool actions with saved parameters"""
try:
from application.agents.tools.tool_manager import ToolManager
tool_name = tool_doc.get("name")
tool_config = tool_doc.get("config", {}).copy()
tool_config["tool_id"] = str(tool_doc["_id"])
tool_manager = ToolManager(config={tool_name: tool_config})
user_id = self.initial_user_id or "local"
tool = tool_manager.load_tool(tool_name, tool_config, user_id=user_id)
if not tool:
logger.debug(f"Tool '{tool_name}' failed to load")
return None
tool_actions = tool.get_actions_metadata()
if not tool_actions:
logger.debug(f"Tool '{tool_name}' has no actions")
return None
saved_actions = tool_doc.get("actions", [])
include_all_actions = required_actions is None or (
required_actions and None in required_actions
)
allowed_actions: Set[str] = (
{action for action in required_actions if isinstance(action, str)}
if required_actions
else set()
)
action_results = {}
for action_meta in tool_actions:
action_name = action_meta.get("name")
if action_name is None:
continue
if (
not include_all_actions
and allowed_actions
and action_name not in allowed_actions
):
continue
try:
saved_action = None
for sa in saved_actions:
if sa.get("name") == action_name:
saved_action = sa
break
action_params = action_meta.get("parameters", {})
properties = action_params.get("properties", {})
kwargs = {}
for param_name, param_spec in properties.items():
if saved_action:
saved_props = saved_action.get("parameters", {}).get(
"properties", {}
)
if param_name in saved_props:
param_value = saved_props[param_name].get("value")
if param_value is not None:
kwargs[param_name] = param_value
continue
if param_name in tool_config:
kwargs[param_name] = tool_config[param_name]
elif "default" in param_spec:
kwargs[param_name] = param_spec["default"]
result = tool.execute_action(action_name, **kwargs)
action_results[action_name] = result
except Exception as e:
logger.debug(
f"Action '{action_name}' execution failed: {type(e).__name__}"
)
continue
return action_results if action_results else None
except Exception as e:
logger.debug(f"Tool pre-fetch failed for '{tool_name}': {type(e).__name__}")
return None
def _get_prompt_content(self) -> Optional[str]:
"""Retrieve and cache the raw prompt content for the current agent configuration."""
if self._prompt_content is not None:
return self._prompt_content
prompt_id = (
self.agent_config.get("prompt_id")
if isinstance(self.agent_config, dict)
else None
)
if not prompt_id:
return None
try:
self._prompt_content = get_prompt(prompt_id, self.prompts_collection)
except ValueError as e:
logger.debug(f"Invalid prompt ID '{prompt_id}': {str(e)}")
self._prompt_content = None
except Exception as e:
logger.debug(f"Failed to fetch prompt '{prompt_id}': {type(e).__name__}")
self._prompt_content = None
return self._prompt_content
def _get_required_tool_actions(self) -> Optional[Dict[str, Set[Optional[str]]]]:
"""Determine which tool actions are referenced in the prompt template"""
if self._required_tool_actions is not None:
return self._required_tool_actions
prompt_content = self._get_prompt_content()
if prompt_content is None:
return None
if "{{" not in prompt_content or "}}" not in prompt_content:
self._required_tool_actions = {}
return self._required_tool_actions
try:
from application.templates.template_engine import TemplateEngine
template_engine = TemplateEngine()
usages = template_engine.extract_tool_usages(prompt_content)
self._required_tool_actions = usages
return self._required_tool_actions
except Exception as e:
logger.debug(f"Failed to extract tool usages: {type(e).__name__}")
self._required_tool_actions = {}
return self._required_tool_actions
def _fetch_memory_tool_data(
self, tool_doc: Dict[str, Any]
) -> Optional[Dict[str, Any]]:
"""Fetch memory tool data for pre-injection into prompt"""
try:
tool_config = tool_doc.get("config", {}).copy()
tool_config["tool_id"] = str(tool_doc["_id"])
from application.agents.tools.memory import MemoryTool
memory_tool = MemoryTool(tool_config, self.initial_user_id)
root_view = memory_tool.execute_action("view", path="/")
if "Error:" in root_view or not root_view.strip():
return None
return {"root": root_view, "available": True}
except Exception as e:
logger.warning(f"Failed to fetch memory tool data: {str(e)}")
return None
def create_agent(
self,
docs_together: Optional[str] = None,
docs: Optional[list] = None,
tools_data: Optional[Dict[str, Any]] = None,
):
"""Create and return the configured agent with rendered prompt"""
raw_prompt = self._get_prompt_content()
if raw_prompt is None:
raw_prompt = get_prompt(
self.agent_config["prompt_id"], self.prompts_collection
)
self._prompt_content = raw_prompt
rendered_prompt = self.prompt_renderer.render_prompt(
prompt_content=raw_prompt,
user_id=self.initial_user_id,
request_id=self.data.get("request_id"),
passthrough_data=self.data.get("passthrough"),
docs=docs,
docs_together=docs_together,
tools_data=tools_data,
)
provider = (
get_provider_from_model_id(self.model_id)
if self.model_id
else settings.LLM_PROVIDER
)
system_api_key = get_api_key_for_provider(provider or settings.LLM_PROVIDER)
return AgentCreator.create_agent(
self.agent_config["agent_type"],
endpoint="stream",
llm_name=provider or settings.LLM_PROVIDER,
model_id=self.model_id,
api_key=system_api_key,
user_api_key=self.agent_config["user_api_key"],
prompt=rendered_prompt,
chat_history=self.history,
retrieved_docs=self.retrieved_docs,
decoded_token=self.decoded_token,
attachments=self.attachments,
json_schema=self.agent_config.get("json_schema"),
)

View File

@@ -23,15 +23,9 @@ from application.core.settings import settings
from application.api import api
from application.utils import (
check_required_fields
)
from application.parser.connectors.connector_creator import ConnectorCreator
mongo = MongoDB.get_client()
db = mongo[settings.MONGO_DB_NAME]
sources_collection = db["sources"]
@@ -43,185 +37,6 @@ api.add_namespace(connectors_ns)
@connectors_ns.route("/api/connectors/upload")
class UploadConnector(Resource):
@api.expect(
api.model(
"ConnectorUploadModel",
{
"user": fields.String(required=True, description="User ID"),
"source": fields.String(
required=True, description="Source type (google_drive, github, etc.)"
),
"name": fields.String(required=True, description="Job name"),
"data": fields.String(required=True, description="Configuration data"),
"repo_url": fields.String(description="GitHub repository URL"),
},
)
)
@api.doc(
description="Uploads connector source for vectorization",
)
def post(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
data = request.form
required_fields = ["user", "source", "name", "data"]
missing_fields = check_required_fields(data, required_fields)
if missing_fields:
return missing_fields
try:
config = json.loads(data["data"])
source_data = None
sync_frequency = config.get("sync_frequency", "never")
if data["source"] == "github":
source_data = config.get("repo_url")
elif data["source"] in ["crawler", "url"]:
source_data = config.get("url")
elif data["source"] == "reddit":
source_data = config
elif data["source"] in ConnectorCreator.get_supported_connectors():
session_token = config.get("session_token")
if not session_token:
return make_response(jsonify({
"success": False,
"error": f"Missing session_token in {data['source']} configuration"
}), 400)
file_ids = config.get("file_ids", [])
if isinstance(file_ids, str):
file_ids = [id.strip() for id in file_ids.split(',') if id.strip()]
elif not isinstance(file_ids, list):
file_ids = []
folder_ids = config.get("folder_ids", [])
if isinstance(folder_ids, str):
folder_ids = [id.strip() for id in folder_ids.split(',') if id.strip()]
elif not isinstance(folder_ids, list):
folder_ids = []
config["file_ids"] = file_ids
config["folder_ids"] = folder_ids
task = ingest_connector_task.delay(
job_name=data["name"],
user=decoded_token.get("sub"),
source_type=data["source"],
session_token=session_token,
file_ids=file_ids,
folder_ids=folder_ids,
recursive=config.get("recursive", False),
retriever=config.get("retriever", "classic"),
sync_frequency=sync_frequency
)
return make_response(jsonify({"success": True, "task_id": task.id}), 200)
task = ingest_connector_task.delay(
source_data=source_data,
job_name=data["name"],
user=decoded_token.get("sub"),
loader=data["source"],
sync_frequency=sync_frequency
)
except Exception as err:
current_app.logger.error(
f"Error uploading connector source: {err}", exc_info=True
)
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify({"success": True, "task_id": task.id}), 200)
@connectors_ns.route("/api/connectors/task_status")
class ConnectorTaskStatus(Resource):
task_status_model = api.model(
"ConnectorTaskStatusModel",
{"task_id": fields.String(required=True, description="Task ID")},
)
@api.expect(task_status_model)
@api.doc(description="Get connector task status")
def get(self):
task_id = request.args.get("task_id")
if not task_id:
return make_response(
jsonify({"success": False, "message": "Task ID is required"}), 400
)
try:
from application.celery_init import celery
task = celery.AsyncResult(task_id)
task_meta = task.info
print(f"Task status: {task.status}")
if not isinstance(
task_meta, (dict, list, str, int, float, bool, type(None))
):
task_meta = str(task_meta)
except Exception as err:
current_app.logger.error(f"Error getting task status: {err}", exc_info=True)
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify({"status": task.status, "result": task_meta}), 200)
@connectors_ns.route("/api/connectors/sources")
class ConnectorSources(Resource):
@api.doc(description="Get connector sources")
def get(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
try:
sources = sources_collection.find({"user": user, "type": "connector:file"}).sort("date", -1)
connector_sources = []
for source in sources:
connector_sources.append({
"id": str(source["_id"]),
"name": source.get("name"),
"date": source.get("date"),
"type": source.get("type"),
"source": source.get("source"),
"tokens": source.get("tokens", ""),
"retriever": source.get("retriever", "classic"),
"syncFrequency": source.get("sync_frequency", ""),
})
except Exception as err:
current_app.logger.error(f"Error retrieving connector sources: {err}", exc_info=True)
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify(connector_sources), 200)
@connectors_ns.route("/api/connectors/delete")
class DeleteConnectorSource(Resource):
@api.doc(
description="Delete a connector source",
params={"source_id": "The source ID to delete"},
)
def delete(self):
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
source_id = request.args.get("source_id")
if not source_id:
return make_response(
jsonify({"success": False, "message": "source_id is required"}), 400
)
try:
result = sources_collection.delete_one(
{"_id": ObjectId(source_id), "user": decoded_token.get("sub")}
)
if result.deleted_count == 0:
return make_response(
jsonify({"success": False, "message": "Source not found"}), 404
)
except Exception as err:
current_app.logger.error(
f"Error deleting connector source: {err}", exc_info=True
)
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify({"success": True}), 200)
@connectors_ns.route("/api/connectors/auth")
class ConnectorAuth(Resource):
@api.doc(description="Get connector OAuth authorization URL", params={"provider": "Connector provider (e.g., google_drive)"})
@@ -337,27 +152,6 @@ class ConnectorsCallback(Resource):
return redirect("/api/connectors/callback-status?status=error&message=Authentication+failed.+Please+try+again+and+make+sure+to+grant+all+requested+permissions.")
@connectors_ns.route("/api/connectors/refresh")
class ConnectorRefresh(Resource):
@api.expect(api.model("ConnectorRefreshModel", {"provider": fields.String(required=True), "refresh_token": fields.String(required=True)}))
@api.doc(description="Refresh connector access token")
def post(self):
try:
data = request.get_json()
provider = data.get('provider')
refresh_token = data.get('refresh_token')
if not provider or not refresh_token:
return make_response(jsonify({"success": False, "error": "provider and refresh_token are required"}), 400)
auth = ConnectorCreator.create_auth(provider)
token_info = auth.refresh_access_token(refresh_token)
return make_response(jsonify({"success": True, "token_info": token_info}), 200)
except Exception as e:
current_app.logger.error(f"Error refreshing token for connector: {e}")
return make_response(jsonify({"success": False, "error": str(e)}), 500)
@connectors_ns.route("/api/connectors/files")
class ConnectorFiles(Resource):
@api.expect(api.model("ConnectorFilesModel", {

View File

@@ -19,6 +19,7 @@ from application.api.user.base import (
storage,
users_collection,
)
from application.core.settings import settings
from application.utils import (
check_required_fields,
generate_image_url,
@@ -74,6 +75,14 @@ class GetAgent(Resource):
"agent_type": agent.get("agent_type", ""),
"status": agent.get("status", ""),
"json_schema": agent.get("json_schema"),
"limited_token_mode": agent.get("limited_token_mode", False),
"token_limit": agent.get(
"token_limit", settings.DEFAULT_AGENT_LIMITS["token_limit"]
),
"limited_request_mode": agent.get("limited_request_mode", False),
"request_limit": agent.get(
"request_limit", settings.DEFAULT_AGENT_LIMITS["request_limit"]
),
"created_at": agent.get("createdAt", ""),
"updated_at": agent.get("updatedAt", ""),
"last_used_at": agent.get("lastUsedAt", ""),
@@ -86,6 +95,8 @@ class GetAgent(Resource):
"shared": agent.get("shared_publicly", False),
"shared_metadata": agent.get("shared_metadata", {}),
"shared_token": agent.get("shared_token", ""),
"models": agent.get("models", []),
"default_model_id": agent.get("default_model_id", ""),
}
return make_response(jsonify(data), 200)
except Exception as e:
@@ -143,6 +154,14 @@ class GetAgents(Resource):
"agent_type": agent.get("agent_type", ""),
"status": agent.get("status", ""),
"json_schema": agent.get("json_schema"),
"limited_token_mode": agent.get("limited_token_mode", False),
"token_limit": agent.get(
"token_limit", settings.DEFAULT_AGENT_LIMITS["token_limit"]
),
"limited_request_mode": agent.get("limited_request_mode", False),
"request_limit": agent.get(
"request_limit", settings.DEFAULT_AGENT_LIMITS["request_limit"]
),
"created_at": agent.get("createdAt", ""),
"updated_at": agent.get("updatedAt", ""),
"last_used_at": agent.get("lastUsedAt", ""),
@@ -155,6 +174,8 @@ class GetAgents(Resource):
"shared": agent.get("shared_publicly", False),
"shared_metadata": agent.get("shared_metadata", {}),
"shared_token": agent.get("shared_token", ""),
"models": agent.get("models", []),
"default_model_id": agent.get("default_model_id", ""),
}
for agent in agents
if "source" in agent or "retriever" in agent
@@ -199,6 +220,28 @@ class CreateAgent(Resource):
required=False,
description="JSON schema for enforcing structured output format",
),
"limited_token_mode": fields.Boolean(
required=False, description="Whether the agent is in limited token mode"
),
"token_limit": fields.Integer(
required=False, description="Token limit for the agent in limited mode"
),
"limited_request_mode": fields.Boolean(
required=False,
description="Whether the agent is in limited request mode",
),
"request_limit": fields.Integer(
required=False,
description="Request limit for the agent in limited mode",
),
"models": fields.List(
fields.String,
required=False,
description="List of available model IDs for this agent",
),
"default_model_id": fields.String(
required=False, description="Default model ID for this agent"
),
},
)
@@ -227,6 +270,11 @@ class CreateAgent(Resource):
data["json_schema"] = json.loads(data["json_schema"])
except json.JSONDecodeError:
data["json_schema"] = None
if "models" in data:
try:
data["models"] = json.loads(data["models"])
except json.JSONDecodeError:
data["models"] = []
print(f"Received data: {data}")
# Validate JSON schema if provided
@@ -344,10 +392,32 @@ class CreateAgent(Resource):
"agent_type": data.get("agent_type", ""),
"status": data.get("status"),
"json_schema": data.get("json_schema"),
"limited_token_mode": (
data.get("limited_token_mode") == "True"
if isinstance(data.get("limited_token_mode"), str)
else bool(data.get("limited_token_mode", False))
),
"token_limit": int(
data.get(
"token_limit", settings.DEFAULT_AGENT_LIMITS["token_limit"]
)
),
"limited_request_mode": (
data.get("limited_request_mode") == "True"
if isinstance(data.get("limited_request_mode"), str)
else bool(data.get("limited_request_mode", False))
),
"request_limit": int(
data.get(
"request_limit", settings.DEFAULT_AGENT_LIMITS["request_limit"]
)
),
"createdAt": datetime.datetime.now(datetime.timezone.utc),
"updatedAt": datetime.datetime.now(datetime.timezone.utc),
"lastUsedAt": None,
"key": key,
"models": data.get("models", []),
"default_model_id": data.get("default_model_id", ""),
}
if new_agent["chunks"] == "":
new_agent["chunks"] = "2"
@@ -399,6 +469,28 @@ class UpdateAgent(Resource):
required=False,
description="JSON schema for enforcing structured output format",
),
"limited_token_mode": fields.Boolean(
required=False, description="Whether the agent is in limited token mode"
),
"token_limit": fields.Integer(
required=False, description="Token limit for the agent in limited mode"
),
"limited_request_mode": fields.Boolean(
require=False,
description="Whether the agent is in limited request mode",
),
"request_limit": fields.Integer(
required=False,
description="Request limit for the agent in limited mode",
),
"models": fields.List(
fields.String,
required=False,
description="List of available model IDs for this agent",
),
"default_model_id": fields.String(
required=False, description="Default model ID for this agent"
),
},
)
@@ -422,7 +514,7 @@ class UpdateAgent(Resource):
data = request.get_json()
else:
data = request.form.to_dict()
json_fields = ["tools", "sources", "json_schema"]
json_fields = ["tools", "sources", "json_schema", "models"]
for field in json_fields:
if field in data and data[field]:
try:
@@ -486,6 +578,12 @@ class UpdateAgent(Resource):
"agent_type",
"status",
"json_schema",
"limited_token_mode",
"token_limit",
"limited_request_mode",
"request_limit",
"models",
"default_model_id",
]
for field in allowed_fields:
@@ -602,6 +700,74 @@ class UpdateAgent(Resource):
update_fields[field] = json_schema
else:
update_fields[field] = None
elif field == "limited_token_mode":
raw_value = data.get("limited_token_mode", False)
bool_value = (
raw_value == "True"
if isinstance(raw_value, str)
else bool(raw_value)
)
update_fields[field] = bool_value
if bool_value and data.get("token_limit") is None:
return make_response(
jsonify(
{
"success": False,
"message": "Token limit must be provided when limited token mode is enabled",
}
),
400,
)
elif field == "limited_request_mode":
raw_value = data.get("limited_request_mode", False)
bool_value = (
raw_value == "True"
if isinstance(raw_value, str)
else bool(raw_value)
)
update_fields[field] = bool_value
if bool_value and data.get("request_limit") is None:
return make_response(
jsonify(
{
"success": False,
"message": "Request limit must be provided when limited request mode is enabled",
}
),
400,
)
elif field == "token_limit":
token_limit = data.get("token_limit")
# Convert to int and store
update_fields[field] = int(token_limit) if token_limit else 0
# Validate consistency with mode
if update_fields[field] > 0 and not data.get("limited_token_mode"):
return make_response(
jsonify(
{
"success": False,
"message": "Token limit cannot be set when limited token mode is disabled",
}
),
400,
)
elif field == "request_limit":
request_limit = data.get("request_limit")
update_fields[field] = int(request_limit) if request_limit else 0
if update_fields[field] > 0 and not data.get("limited_request_mode"):
return make_response(
jsonify(
{
"success": False,
"message": "Request limit cannot be set when limited request mode is disabled",
}
),
400,
)
else:
value = data[field]
if field in ["name", "description", "prompt_id", "agent_type"]:
@@ -822,6 +988,70 @@ class PinnedAgents(Resource):
return make_response(jsonify(list_pinned_agents), 200)
@agents_ns.route("/template_agents")
class GetTemplateAgents(Resource):
@api.doc(description="Get template/premade agents")
def get(self):
try:
template_agents = agents_collection.find({"user": "system"})
template_agents = [
{
"id": str(agent["_id"]),
"name": agent["name"],
"description": agent["description"],
"image": agent.get("image", ""),
}
for agent in template_agents
]
return make_response(jsonify(template_agents), 200)
except Exception as e:
current_app.logger.error(f"Template agents fetch error: {e}", exc_info=True)
return make_response(jsonify({"success": False}), 400)
@agents_ns.route("/adopt_agent")
class AdoptAgent(Resource):
@api.doc(params={"id": "Agent ID"}, description="Adopt an agent by ID")
def post(self):
if not (decoded_token := request.decoded_token):
return make_response(jsonify({"success": False}), 401)
if not (agent_id := request.args.get("id")):
return make_response(
jsonify({"success": False, "message": "ID required"}), 400
)
try:
agent = agents_collection.find_one(
{"_id": ObjectId(agent_id), "user": "system"}
)
if not agent:
return make_response(jsonify({"status": "Not found"}), 404)
new_agent = agent.copy()
new_agent.pop("_id", None)
new_agent["user"] = decoded_token["sub"]
new_agent["status"] = "published"
new_agent["lastUsedAt"] = datetime.datetime.now(datetime.timezone.utc)
new_agent["key"] = str(uuid.uuid4())
insert_result = agents_collection.insert_one(new_agent)
response_agent = new_agent.copy()
response_agent.pop("_id", None)
response_agent["id"] = str(insert_result.inserted_id)
response_agent["tool_details"] = resolve_tool_details(
response_agent.get("tools", [])
)
if isinstance(response_agent.get("source"), DBRef):
response_agent["source"] = str(response_agent["source"].id)
return make_response(
jsonify({"success": True, "agent": response_agent}), 200
)
except Exception as e:
current_app.logger.error(f"Agent adopt error: {e}", exc_info=True)
return make_response(jsonify({"success": False}), 400)
@agents_ns.route("/pin_agent")
class PinAgent(Resource):
@api.doc(params={"id": "ID of the agent"}, description="Pin or unpin an agent")

View File

@@ -9,6 +9,7 @@ from flask import current_app, jsonify, make_response, request
from flask_restx import fields, Namespace, Resource
from application.api import api
from application.core.settings import settings
from application.api.user.base import (
agents_collection,
db,
@@ -75,6 +76,10 @@ class SharedAgent(Resource):
"agent_type": shared_agent.get("agent_type", ""),
"status": shared_agent.get("status", ""),
"json_schema": shared_agent.get("json_schema"),
"limited_token_mode": shared_agent.get("limited_token_mode", False),
"token_limit": shared_agent.get("token_limit", settings.DEFAULT_AGENT_LIMITS["token_limit"]),
"limited_request_mode": shared_agent.get("limited_request_mode", False),
"request_limit": shared_agent.get("request_limit", settings.DEFAULT_AGENT_LIMITS["request_limit"]),
"created_at": shared_agent.get("createdAt", ""),
"updated_at": shared_agent.get("updatedAt", ""),
"shared": shared_agent.get("shared_publicly", False),
@@ -149,6 +154,10 @@ class SharedAgents(Resource):
"agent_type": agent.get("agent_type", ""),
"status": agent.get("status", ""),
"json_schema": agent.get("json_schema"),
"limited_token_mode": agent.get("limited_token_mode", False),
"token_limit": agent.get("token_limit", settings.DEFAULT_AGENT_LIMITS["token_limit"]),
"limited_request_mode": agent.get("limited_request_mode", False),
"request_limit": agent.get("request_limit", settings.DEFAULT_AGENT_LIMITS["request_limit"]),
"created_at": agent.get("createdAt", ""),
"updated_at": agent.get("updatedAt", ""),
"pinned": str(agent["_id"]) in pinned_ids,

View File

@@ -10,7 +10,7 @@ from application.api import api
from application.api.user.base import agents_collection, storage
from application.api.user.tasks import store_attachment
from application.core.settings import settings
from application.tts.google_tts import GoogleTTS
from application.tts.tts_creator import TTSCreator
from application.utils import safe_filename
@@ -25,7 +25,7 @@ class StoreAttachment(Resource):
api.model(
"AttachmentModel",
{
"file": fields.Raw(required=True, description="File to upload"),
"file": fields.Raw(required=True, description="File(s) to upload"),
"api_key": fields.String(
required=False, description="API key (optional)"
),
@@ -33,18 +33,24 @@ class StoreAttachment(Resource):
)
)
@api.doc(
description="Stores a single attachment without vectorization or training. Supports user or API key authentication."
description="Stores one or multiple attachments without vectorization or training. Supports user or API key authentication."
)
def post(self):
decoded_token = getattr(request, "decoded_token", None)
api_key = request.form.get("api_key") or request.args.get("api_key")
file = request.files.get("file")
if not file or file.filename == "":
files = request.files.getlist("file")
if not files:
single_file = request.files.get("file")
if single_file:
files = [single_file]
if not files or all(f.filename == "" for f in files):
return make_response(
jsonify({"status": "error", "message": "Missing file"}),
jsonify({"status": "error", "message": "Missing file(s)"}),
400,
)
user = None
if decoded_token:
user = safe_filename(decoded_token.get("sub"))
@@ -59,32 +65,74 @@ class StoreAttachment(Resource):
return make_response(
jsonify({"success": False, "message": "Authentication required"}), 401
)
try:
attachment_id = ObjectId()
original_filename = safe_filename(os.path.basename(file.filename))
relative_path = f"{settings.UPLOAD_FOLDER}/{user}/attachments/{str(attachment_id)}/{original_filename}"
tasks = []
errors = []
original_file_count = len(files)
for idx, file in enumerate(files):
try:
attachment_id = ObjectId()
original_filename = safe_filename(os.path.basename(file.filename))
relative_path = f"{settings.UPLOAD_FOLDER}/{user}/attachments/{str(attachment_id)}/{original_filename}"
metadata = storage.save_file(file, relative_path)
file_info = {
"filename": original_filename,
"attachment_id": str(attachment_id),
"path": relative_path,
"metadata": metadata,
}
task = store_attachment.delay(file_info, user)
return make_response(
jsonify(
{
"success": True,
"task_id": task.id,
"message": "File uploaded successfully. Processing started.",
metadata = storage.save_file(file, relative_path)
file_info = {
"filename": original_filename,
"attachment_id": str(attachment_id),
"path": relative_path,
"metadata": metadata,
}
),
200,
)
task = store_attachment.delay(file_info, user)
tasks.append({
"task_id": task.id,
"filename": original_filename,
"attachment_id": str(attachment_id),
})
except Exception as file_err:
current_app.logger.error(f"Error processing file {idx} ({file.filename}): {file_err}", exc_info=True)
errors.append({
"filename": file.filename,
"error": str(file_err)
})
if not tasks:
error_msg = "No valid files to upload"
if errors:
error_msg += f". Errors: {errors}"
return make_response(
jsonify({"status": "error", "message": error_msg, "errors": errors}),
400,
)
if original_file_count == 1 and len(tasks) == 1:
current_app.logger.info("Returning single task_id response")
return make_response(
jsonify(
{
"success": True,
"task_id": tasks[0]["task_id"],
"message": "File uploaded successfully. Processing started.",
}
),
200,
)
else:
response_data = {
"success": True,
"tasks": tasks,
"message": f"{len(tasks)} file(s) uploaded successfully. Processing started.",
}
if errors:
response_data["errors"] = errors
response_data["message"] += f" {len(errors)} file(s) failed."
return make_response(
jsonify(response_data),
200,
)
except Exception as err:
current_app.logger.error(f"Error storing attachment: {err}", exc_info=True)
return make_response(jsonify({"success": False, "error": str(err)}), 400)
@@ -133,7 +181,7 @@ class TextToSpeech(Resource):
data = request.get_json()
text = data["text"]
try:
tts_instance = GoogleTTS()
tts_instance = TTSCreator.create_tts(settings.TTS_PROVIDER)
audio_base64, detected_language = tts_instance.text_to_speech(text)
return make_response(
jsonify(

View File

@@ -0,0 +1,3 @@
from .routes import models_ns
__all__ = ["models_ns"]

View File

@@ -0,0 +1,25 @@
from flask import current_app, jsonify, make_response
from flask_restx import Namespace, Resource
from application.core.model_settings import ModelRegistry
models_ns = Namespace("models", description="Available models", path="/api")
@models_ns.route("/models")
class ModelsListResource(Resource):
def get(self):
"""Get list of available models with their capabilities."""
try:
registry = ModelRegistry.get_instance()
models = registry.get_enabled_models()
response = {
"models": [model.to_dict() for model in models],
"default_model_id": registry.default_model_id,
"count": len(models),
}
except Exception as err:
current_app.logger.error(f"Error fetching models: {err}", exc_info=True)
return make_response(jsonify({"success": False}), 500)
return make_response(jsonify(response), 200)

View File

@@ -10,6 +10,7 @@ from .agents import agents_ns, agents_sharing_ns, agents_webhooks_ns
from .analytics import analytics_ns
from .attachments import attachments_ns
from .conversations import conversations_ns
from .models import models_ns
from .prompts import prompts_ns
from .sharing import sharing_ns
from .sources import sources_chunks_ns, sources_ns, sources_upload_ns
@@ -27,6 +28,9 @@ api.add_namespace(attachments_ns)
# Conversations
api.add_namespace(conversations_ns)
# Models
api.add_namespace(models_ns)
# Agents (main, sharing, webhooks)
api.add_namespace(agents_ns)
api.add_namespace(agents_sharing_ns)

View File

@@ -13,7 +13,6 @@ from application.api.user.base import (
agents_collection,
attachments_collection,
conversations_collection,
db,
shared_conversations_collections,
)
from application.utils import check_required_fields
@@ -97,9 +96,7 @@ class ShareConversation(Resource):
api_uuid = pre_existing_api_document["key"]
pre_existing = shared_conversations_collections.find_one(
{
"conversation_id": DBRef(
"conversations", ObjectId(conversation_id)
),
"conversation_id": ObjectId(conversation_id),
"isPromptable": is_promptable,
"first_n_queries": current_n_queries,
"user": user,
@@ -120,10 +117,7 @@ class ShareConversation(Resource):
shared_conversations_collections.insert_one(
{
"uuid": explicit_binary,
"conversation_id": {
"$ref": "conversations",
"$id": ObjectId(conversation_id),
},
"conversation_id": ObjectId(conversation_id),
"isPromptable": is_promptable,
"first_n_queries": current_n_queries,
"user": user,
@@ -154,10 +148,7 @@ class ShareConversation(Resource):
shared_conversations_collections.insert_one(
{
"uuid": explicit_binary,
"conversation_id": {
"$ref": "conversations",
"$id": ObjectId(conversation_id),
},
"conversation_id": ObjectId(conversation_id),
"isPromptable": is_promptable,
"first_n_queries": current_n_queries,
"user": user,
@@ -175,9 +166,7 @@ class ShareConversation(Resource):
)
pre_existing = shared_conversations_collections.find_one(
{
"conversation_id": DBRef(
"conversations", ObjectId(conversation_id)
),
"conversation_id": ObjectId(conversation_id),
"isPromptable": is_promptable,
"first_n_queries": current_n_queries,
"user": user,
@@ -197,10 +186,7 @@ class ShareConversation(Resource):
shared_conversations_collections.insert_one(
{
"uuid": explicit_binary,
"conversation_id": {
"$ref": "conversations",
"$id": ObjectId(conversation_id),
},
"conversation_id": ObjectId(conversation_id),
"isPromptable": is_promptable,
"first_n_queries": current_n_queries,
"user": user,
@@ -233,10 +219,12 @@ class GetPubliclySharedConversations(Resource):
if (
shared
and "conversation_id" in shared
and isinstance(shared["conversation_id"], DBRef)
):
conversation_ref = shared["conversation_id"]
conversation = db.dereference(conversation_ref)
# conversation_id is now stored as an ObjectId, not a DBRef
conversation_id = shared["conversation_id"]
conversation = conversations_collection.find_one(
{"_id": conversation_id}
)
if conversation is None:
return make_response(
jsonify(

View File

@@ -2,12 +2,10 @@
import json
import math
import os
from bson.objectid import ObjectId
from flask import current_app, jsonify, make_response, redirect, request
from flask_restx import fields, Namespace, Resource
from werkzeug.utils import secure_filename
from application.api import api
from application.api.user.base import sources_collection
@@ -136,31 +134,6 @@ class PaginatedSources(Resource):
return make_response(jsonify({"success": False}), 400)
@sources_ns.route("/docs_check")
class CheckDocs(Resource):
check_docs_model = api.model(
"CheckDocsModel",
{"docs": fields.String(required=True, description="Document name")},
)
@api.expect(check_docs_model)
@api.doc(description="Check if document exists")
def post(self):
data = request.get_json()
required_fields = ["docs"]
missing_fields = check_required_fields(data, required_fields)
if missing_fields:
return missing_fields
try:
vectorstore = "vectors/" + secure_filename(data["docs"])
if os.path.exists(vectorstore) or data["docs"] == "default":
return {"status": "exists"}, 200
except Exception as err:
current_app.logger.error(f"Error checking document: {err}", exc_info=True)
return make_response(jsonify({"success": False}), 400)
return make_response(jsonify({"status": "not found"}), 404)
@sources_ns.route("/delete_by_ids")
class DeleteByIds(Resource):
@api.doc(

View File

@@ -562,10 +562,21 @@ class TaskStatus(Resource):
task = celery.AsyncResult(task_id)
task_meta = task.info
print(f"Task status: {task.status}")
if task.status == "PENDING":
inspect = celery.control.inspect()
active_workers = inspect.ping()
if not active_workers:
raise ConnectionError("Service unavailable")
if not isinstance(
task_meta, (dict, list, str, int, float, bool, type(None))
):
task_meta = str(task_meta) # Convert to a string representation
except ConnectionError as err:
return make_response(
jsonify({"success": False, "message": str(err)}), 503
)
except Exception as err:
current_app.logger.error(f"Error getting task status: {err}", exc_info=True)
return make_response(jsonify({"success": False}), 400)

View File

@@ -56,9 +56,10 @@ class GetTools(Resource):
tools = user_tools_collection.find({"user": user})
user_tools = []
for tool in tools:
tool["id"] = str(tool["_id"])
tool.pop("_id")
user_tools.append(tool)
tool_copy = {**tool}
tool_copy["id"] = str(tool["_id"])
tool_copy.pop("_id", None)
user_tools.append(tool_copy)
except Exception as err:
current_app.logger.error(f"Error getting user tools: {err}", exc_info=True)
return make_response(jsonify({"success": False}), 400)

View File

@@ -0,0 +1,223 @@
"""
Model configurations for all supported LLM providers.
"""
from application.core.model_settings import (
AvailableModel,
ModelCapabilities,
ModelProvider,
)
OPENAI_ATTACHMENTS = [
"application/pdf",
"image/png",
"image/jpeg",
"image/jpg",
"image/webp",
"image/gif",
]
GOOGLE_ATTACHMENTS = [
"application/pdf",
"image/png",
"image/jpeg",
"image/jpg",
"image/webp",
"image/gif",
]
OPENAI_MODELS = [
AvailableModel(
id="gpt-4o",
provider=ModelProvider.OPENAI,
display_name="GPT-4 Omni",
description="Latest and most capable model",
capabilities=ModelCapabilities(
supports_tools=True,
supports_structured_output=True,
supported_attachment_types=OPENAI_ATTACHMENTS,
context_window=128000,
),
),
AvailableModel(
id="gpt-4o-mini",
provider=ModelProvider.OPENAI,
display_name="GPT-4 Omni Mini",
description="Fast and efficient",
capabilities=ModelCapabilities(
supports_tools=True,
supports_structured_output=True,
supported_attachment_types=OPENAI_ATTACHMENTS,
context_window=128000,
),
),
AvailableModel(
id="gpt-4-turbo",
provider=ModelProvider.OPENAI,
display_name="GPT-4 Turbo",
description="Fast GPT-4 with 128k context",
capabilities=ModelCapabilities(
supports_tools=True,
supports_structured_output=True,
supported_attachment_types=OPENAI_ATTACHMENTS,
context_window=128000,
),
),
AvailableModel(
id="gpt-4",
provider=ModelProvider.OPENAI,
display_name="GPT-4",
description="Most capable model",
capabilities=ModelCapabilities(
supports_tools=True,
supports_structured_output=True,
supported_attachment_types=OPENAI_ATTACHMENTS,
context_window=8192,
),
),
AvailableModel(
id="gpt-3.5-turbo",
provider=ModelProvider.OPENAI,
display_name="GPT-3.5 Turbo",
description="Fast and cost-effective",
capabilities=ModelCapabilities(
supports_tools=True,
context_window=4096,
),
),
]
ANTHROPIC_MODELS = [
AvailableModel(
id="claude-3-5-sonnet-20241022",
provider=ModelProvider.ANTHROPIC,
display_name="Claude 3.5 Sonnet (Latest)",
description="Latest Claude 3.5 Sonnet with enhanced capabilities",
capabilities=ModelCapabilities(
supports_tools=True,
context_window=200000,
),
),
AvailableModel(
id="claude-3-5-sonnet",
provider=ModelProvider.ANTHROPIC,
display_name="Claude 3.5 Sonnet",
description="Balanced performance and capability",
capabilities=ModelCapabilities(
supports_tools=True,
context_window=200000,
),
),
AvailableModel(
id="claude-3-opus",
provider=ModelProvider.ANTHROPIC,
display_name="Claude 3 Opus",
description="Most capable Claude model",
capabilities=ModelCapabilities(
supports_tools=True,
context_window=200000,
),
),
AvailableModel(
id="claude-3-haiku",
provider=ModelProvider.ANTHROPIC,
display_name="Claude 3 Haiku",
description="Fastest Claude model",
capabilities=ModelCapabilities(
supports_tools=True,
context_window=200000,
),
),
]
GOOGLE_MODELS = [
AvailableModel(
id="gemini-flash-latest",
provider=ModelProvider.GOOGLE,
display_name="Gemini Flash (Latest)",
description="Latest experimental Gemini model",
capabilities=ModelCapabilities(
supports_tools=True,
supports_structured_output=True,
supported_attachment_types=GOOGLE_ATTACHMENTS,
context_window=int(1e6),
),
),
AvailableModel(
id="gemini-flash-lite-latest",
provider=ModelProvider.GOOGLE,
display_name="Gemini Flash Lite (Latest)",
description="Fast with huge context window",
capabilities=ModelCapabilities(
supports_tools=True,
supports_structured_output=True,
supported_attachment_types=GOOGLE_ATTACHMENTS,
context_window=int(1e6),
),
),
AvailableModel(
id="gemini-2.5-pro",
provider=ModelProvider.GOOGLE,
display_name="Gemini 2.5 Pro",
description="Most capable Gemini model",
capabilities=ModelCapabilities(
supports_tools=True,
supports_structured_output=True,
supported_attachment_types=GOOGLE_ATTACHMENTS,
context_window=2000000,
),
),
]
GROQ_MODELS = [
AvailableModel(
id="llama-3.3-70b-versatile",
provider=ModelProvider.GROQ,
display_name="Llama 3.3 70B",
description="Latest Llama model with high-speed inference",
capabilities=ModelCapabilities(
supports_tools=True,
context_window=128000,
),
),
AvailableModel(
id="llama-3.1-8b-instant",
provider=ModelProvider.GROQ,
display_name="Llama 3.1 8B",
description="Ultra-fast inference",
capabilities=ModelCapabilities(
supports_tools=True,
context_window=128000,
),
),
AvailableModel(
id="mixtral-8x7b-32768",
provider=ModelProvider.GROQ,
display_name="Mixtral 8x7B",
description="High-speed inference with tools",
capabilities=ModelCapabilities(
supports_tools=True,
context_window=32768,
),
),
]
AZURE_OPENAI_MODELS = [
AvailableModel(
id="azure-gpt-4",
provider=ModelProvider.AZURE_OPENAI,
display_name="Azure OpenAI GPT-4",
description="Azure-hosted GPT model",
capabilities=ModelCapabilities(
supports_tools=True,
supports_structured_output=True,
supported_attachment_types=OPENAI_ATTACHMENTS,
context_window=8192,
),
),
]

View File

@@ -0,0 +1,236 @@
import logging
from dataclasses import dataclass, field
from enum import Enum
from typing import Dict, List, Optional
logger = logging.getLogger(__name__)
class ModelProvider(str, Enum):
OPENAI = "openai"
AZURE_OPENAI = "azure_openai"
ANTHROPIC = "anthropic"
GROQ = "groq"
GOOGLE = "google"
HUGGINGFACE = "huggingface"
LLAMA_CPP = "llama.cpp"
DOCSGPT = "docsgpt"
PREMAI = "premai"
SAGEMAKER = "sagemaker"
NOVITA = "novita"
@dataclass
class ModelCapabilities:
supports_tools: bool = False
supports_structured_output: bool = False
supports_streaming: bool = True
supported_attachment_types: List[str] = field(default_factory=list)
context_window: int = 128000
input_cost_per_token: Optional[float] = None
output_cost_per_token: Optional[float] = None
@dataclass
class AvailableModel:
id: str
provider: ModelProvider
display_name: str
description: str = ""
capabilities: ModelCapabilities = field(default_factory=ModelCapabilities)
enabled: bool = True
base_url: Optional[str] = None
def to_dict(self) -> Dict:
result = {
"id": self.id,
"provider": self.provider.value,
"display_name": self.display_name,
"description": self.description,
"supported_attachment_types": self.capabilities.supported_attachment_types,
"supports_tools": self.capabilities.supports_tools,
"supports_structured_output": self.capabilities.supports_structured_output,
"supports_streaming": self.capabilities.supports_streaming,
"context_window": self.capabilities.context_window,
"enabled": self.enabled,
}
if self.base_url:
result["base_url"] = self.base_url
return result
class ModelRegistry:
_instance = None
_initialized = False
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
if not ModelRegistry._initialized:
self.models: Dict[str, AvailableModel] = {}
self.default_model_id: Optional[str] = None
self._load_models()
ModelRegistry._initialized = True
@classmethod
def get_instance(cls) -> "ModelRegistry":
return cls()
def _load_models(self):
from application.core.settings import settings
self.models.clear()
self._add_docsgpt_models(settings)
if settings.OPENAI_API_KEY or (
settings.LLM_PROVIDER == "openai" and settings.API_KEY
):
self._add_openai_models(settings)
if settings.OPENAI_API_BASE or (
settings.LLM_PROVIDER == "azure_openai" and settings.API_KEY
):
self._add_azure_openai_models(settings)
if settings.ANTHROPIC_API_KEY or (
settings.LLM_PROVIDER == "anthropic" and settings.API_KEY
):
self._add_anthropic_models(settings)
if settings.GOOGLE_API_KEY or (
settings.LLM_PROVIDER == "google" and settings.API_KEY
):
self._add_google_models(settings)
if settings.GROQ_API_KEY or (
settings.LLM_PROVIDER == "groq" and settings.API_KEY
):
self._add_groq_models(settings)
if settings.HUGGINGFACE_API_KEY or (
settings.LLM_PROVIDER == "huggingface" and settings.API_KEY
):
self._add_huggingface_models(settings)
# Default model selection
if settings.LLM_NAME and settings.LLM_NAME in self.models:
self.default_model_id = settings.LLM_NAME
elif settings.LLM_PROVIDER and settings.API_KEY:
for model_id, model in self.models.items():
if model.provider.value == settings.LLM_PROVIDER:
self.default_model_id = model_id
break
else:
self.default_model_id = next(iter(self.models.keys()))
logger.info(
f"ModelRegistry loaded {len(self.models)} models, default: {self.default_model_id}"
)
def _add_openai_models(self, settings):
from application.core.model_configs import OPENAI_MODELS
if settings.OPENAI_API_KEY:
for model in OPENAI_MODELS:
self.models[model.id] = model
return
if settings.LLM_PROVIDER == "openai" and settings.LLM_NAME:
for model in OPENAI_MODELS:
if model.id == settings.LLM_NAME:
self.models[model.id] = model
return
for model in OPENAI_MODELS:
self.models[model.id] = model
def _add_azure_openai_models(self, settings):
from application.core.model_configs import AZURE_OPENAI_MODELS
if settings.LLM_PROVIDER == "azure_openai" and settings.LLM_NAME:
for model in AZURE_OPENAI_MODELS:
if model.id == settings.LLM_NAME:
self.models[model.id] = model
return
for model in AZURE_OPENAI_MODELS:
self.models[model.id] = model
def _add_anthropic_models(self, settings):
from application.core.model_configs import ANTHROPIC_MODELS
if settings.ANTHROPIC_API_KEY:
for model in ANTHROPIC_MODELS:
self.models[model.id] = model
return
if settings.LLM_PROVIDER == "anthropic" and settings.LLM_NAME:
for model in ANTHROPIC_MODELS:
if model.id == settings.LLM_NAME:
self.models[model.id] = model
return
for model in ANTHROPIC_MODELS:
self.models[model.id] = model
def _add_google_models(self, settings):
from application.core.model_configs import GOOGLE_MODELS
if settings.GOOGLE_API_KEY:
for model in GOOGLE_MODELS:
self.models[model.id] = model
return
if settings.LLM_PROVIDER == "google" and settings.LLM_NAME:
for model in GOOGLE_MODELS:
if model.id == settings.LLM_NAME:
self.models[model.id] = model
return
for model in GOOGLE_MODELS:
self.models[model.id] = model
def _add_groq_models(self, settings):
from application.core.model_configs import GROQ_MODELS
if settings.GROQ_API_KEY:
for model in GROQ_MODELS:
self.models[model.id] = model
return
if settings.LLM_PROVIDER == "groq" and settings.LLM_NAME:
for model in GROQ_MODELS:
if model.id == settings.LLM_NAME:
self.models[model.id] = model
return
for model in GROQ_MODELS:
self.models[model.id] = model
def _add_docsgpt_models(self, settings):
model_id = "docsgpt-local"
model = AvailableModel(
id=model_id,
provider=ModelProvider.DOCSGPT,
display_name="DocsGPT Model",
description="Local model",
capabilities=ModelCapabilities(
supports_tools=False,
supported_attachment_types=[],
),
)
self.models[model_id] = model
def _add_huggingface_models(self, settings):
model_id = "huggingface-local"
model = AvailableModel(
id=model_id,
provider=ModelProvider.HUGGINGFACE,
display_name="Hugging Face Model",
description="Local Hugging Face model",
capabilities=ModelCapabilities(
supports_tools=False,
supported_attachment_types=[],
),
)
self.models[model_id] = model
def get_model(self, model_id: str) -> Optional[AvailableModel]:
return self.models.get(model_id)
def get_all_models(self) -> List[AvailableModel]:
return list(self.models.values())
def get_enabled_models(self) -> List[AvailableModel]:
return [m for m in self.models.values() if m.enabled]
def model_exists(self, model_id: str) -> bool:
return model_id in self.models

View File

@@ -0,0 +1,91 @@
from typing import Any, Dict, Optional
from application.core.model_settings import ModelRegistry
def get_api_key_for_provider(provider: str) -> Optional[str]:
"""Get the appropriate API key for a provider"""
from application.core.settings import settings
provider_key_map = {
"openai": settings.OPENAI_API_KEY,
"anthropic": settings.ANTHROPIC_API_KEY,
"google": settings.GOOGLE_API_KEY,
"groq": settings.GROQ_API_KEY,
"huggingface": settings.HUGGINGFACE_API_KEY,
"azure_openai": settings.API_KEY,
"docsgpt": None,
"llama.cpp": None,
}
provider_key = provider_key_map.get(provider)
if provider_key:
return provider_key
return settings.API_KEY
def get_all_available_models() -> Dict[str, Dict[str, Any]]:
"""Get all available models with metadata for API response"""
registry = ModelRegistry.get_instance()
return {model.id: model.to_dict() for model in registry.get_enabled_models()}
def validate_model_id(model_id: str) -> bool:
"""Check if a model ID exists in registry"""
registry = ModelRegistry.get_instance()
return registry.model_exists(model_id)
def get_model_capabilities(model_id: str) -> Optional[Dict[str, Any]]:
"""Get capabilities for a specific model"""
registry = ModelRegistry.get_instance()
model = registry.get_model(model_id)
if model:
return {
"supported_attachment_types": model.capabilities.supported_attachment_types,
"supports_tools": model.capabilities.supports_tools,
"supports_structured_output": model.capabilities.supports_structured_output,
"context_window": model.capabilities.context_window,
}
return None
def get_default_model_id() -> str:
"""Get the system default model ID"""
registry = ModelRegistry.get_instance()
return registry.default_model_id
def get_provider_from_model_id(model_id: str) -> Optional[str]:
"""Get the provider name for a given model_id"""
registry = ModelRegistry.get_instance()
model = registry.get_model(model_id)
if model:
return model.provider.value
return None
def get_token_limit(model_id: str) -> int:
"""
Get context window (token limit) for a model.
Returns model's context_window or default 128000 if model not found.
"""
from application.core.settings import settings
registry = ModelRegistry.get_instance()
model = registry.get_model(model_id)
if model:
return model.capabilities.context_window
return settings.DEFAULT_LLM_TOKEN_LIMIT
def get_base_url_for_model(model_id: str) -> Optional[str]:
"""
Get the custom base_url for a specific model if configured.
Returns None if no custom base_url is set.
"""
registry = ModelRegistry.get_instance()
model = registry.get_model(model_id)
if model:
return model.base_url
return None

View File

@@ -22,11 +22,15 @@ class Settings(BaseSettings):
MONGO_DB_NAME: str = "docsgpt"
LLM_PATH: str = os.path.join(current_dir, "models/docsgpt-7b-f16.gguf")
DEFAULT_MAX_HISTORY: int = 150
LLM_TOKEN_LIMITS: dict = {
"gpt-4o-mini": 128000,
"gpt-3.5-turbo": 4096,
"claude-2": 1e5,
"gemini-2.5-flash": 1e6,
DEFAULT_LLM_TOKEN_LIMIT: int = 128000 # Fallback when model not found in registry
RESERVED_TOKENS: dict = {
"system_prompt": 500,
"current_query": 500,
"safety_buffer": 1000,
}
DEFAULT_AGENT_LIMITS: dict = {
"token_limit": 50000,
"request_limit": 500,
}
UPLOAD_FOLDER: str = "inputs"
PARSE_PDF_AS_IMAGE: bool = False
@@ -51,12 +55,23 @@ class Settings(BaseSettings):
"http://127.0.0.1:7091/api/connectors/callback" ##add redirect url as it is to your provider's console(gcp)
)
# GitHub source
GITHUB_ACCESS_TOKEN: Optional[str] = None # PAT token with read repo access
# LLM Cache
CACHE_REDIS_URL: str = "redis://localhost:6379/2"
API_URL: str = "http://localhost:7091" # backend url for celery worker
API_KEY: Optional[str] = None # LLM api key
API_KEY: Optional[str] = None # LLM api key (used by LLM_PROVIDER)
# Provider-specific API keys (for multi-model support)
OPENAI_API_KEY: Optional[str] = None
ANTHROPIC_API_KEY: Optional[str] = None
GOOGLE_API_KEY: Optional[str] = None
GROQ_API_KEY: Optional[str] = None
HUGGINGFACE_API_KEY: Optional[str] = None
EMBEDDINGS_KEY: Optional[str] = (
None # api key for embeddings (if using openai, just copy API_KEY)
)
@@ -123,7 +138,12 @@ class Settings(BaseSettings):
# Encryption settings
ENCRYPTION_SECRET_KEY: str = "default-docsgpt-encryption-key"
TTS_PROVIDER: str = "google_tts" # google_tts or elevenlabs
ELEVENLABS_API_KEY: Optional[str] = None
# Tool pre-fetch settings
ENABLE_TOOL_PREFETCH: bool = True
path = Path(__file__).parent.parent.absolute()
settings = Settings(_env_file=path.joinpath(".env"), _env_file_encoding="utf-8")

View File

@@ -1,30 +1,41 @@
from application.llm.base import BaseLLM
from anthropic import AI_PROMPT, Anthropic, HUMAN_PROMPT
from application.core.settings import settings
from application.llm.base import BaseLLM
class AnthropicLLM(BaseLLM):
def __init__(self, api_key=None, user_api_key=None, *args, **kwargs):
from anthropic import Anthropic, HUMAN_PROMPT, AI_PROMPT
def __init__(self, api_key=None, user_api_key=None, base_url=None, *args, **kwargs):
super().__init__(*args, **kwargs)
self.api_key = (
api_key or settings.ANTHROPIC_API_KEY
) # If not provided, use a default from settings
self.api_key = api_key or settings.ANTHROPIC_API_KEY or settings.API_KEY
self.user_api_key = user_api_key
self.anthropic = Anthropic(api_key=self.api_key)
# Use custom base_url if provided
if base_url:
self.anthropic = Anthropic(api_key=self.api_key, base_url=base_url)
else:
self.anthropic = Anthropic(api_key=self.api_key)
self.HUMAN_PROMPT = HUMAN_PROMPT
self.AI_PROMPT = AI_PROMPT
def _raw_gen(
self, baseself, model, messages, stream=False, tools=None, max_tokens=300, **kwargs
self,
baseself,
model,
messages,
stream=False,
tools=None,
max_tokens=300,
**kwargs,
):
context = messages[0]["content"]
user_question = messages[-1]["content"]
prompt = f"### Context \n {context} \n ### Question \n {user_question}"
if stream:
return self.gen_stream(model, prompt, stream, max_tokens, **kwargs)
completion = self.anthropic.completions.create(
model=model,
max_tokens_to_sample=max_tokens,
@@ -34,7 +45,14 @@ class AnthropicLLM(BaseLLM):
return completion.completion
def _raw_gen_stream(
self, baseself, model, messages, stream=True, tools=None, max_tokens=300, **kwargs
self,
baseself,
model,
messages,
stream=True,
tools=None,
max_tokens=300,
**kwargs,
):
context = messages[0]["content"]
user_question = messages[-1]["content"]
@@ -46,5 +64,9 @@ class AnthropicLLM(BaseLLM):
stream=True,
)
for completion in stream_response:
yield completion.completion
try:
for completion in stream_response:
yield completion.completion
finally:
if hasattr(stream_response, "close"):
stream_response.close()

View File

@@ -13,30 +13,32 @@ class BaseLLM(ABC):
def __init__(
self,
decoded_token=None,
model_id=None,
base_url=None,
):
self.decoded_token = decoded_token
self.model_id = model_id
self.base_url = base_url
self.token_usage = {"prompt_tokens": 0, "generated_tokens": 0}
self.fallback_provider = settings.FALLBACK_LLM_PROVIDER
self.fallback_model_name = settings.FALLBACK_LLM_NAME
self.fallback_llm_api_key = settings.FALLBACK_LLM_API_KEY
self._fallback_llm = None
self._fallback_sequence_index = 0
@property
def fallback_llm(self):
"""Lazy-loaded fallback LLM instance."""
if (
self._fallback_llm is None
and self.fallback_provider
and self.fallback_model_name
):
"""Lazy-loaded fallback LLM from FALLBACK_* settings."""
if self._fallback_llm is None and settings.FALLBACK_LLM_PROVIDER:
try:
from application.llm.llm_creator import LLMCreator
self._fallback_llm = LLMCreator.create_llm(
self.fallback_provider,
self.fallback_llm_api_key,
None,
self.decoded_token,
settings.FALLBACK_LLM_PROVIDER,
api_key=settings.FALLBACK_LLM_API_KEY or settings.API_KEY,
user_api_key=None,
decoded_token=self.decoded_token,
model_id=settings.FALLBACK_LLM_NAME,
)
logger.info(
f"Fallback LLM initialized: {settings.FALLBACK_LLM_PROVIDER}/{settings.FALLBACK_LLM_NAME}"
)
except Exception as e:
logger.error(
@@ -44,11 +46,17 @@ class BaseLLM(ABC):
)
return self._fallback_llm
@staticmethod
def _remove_null_values(args_dict):
if not isinstance(args_dict, dict):
return args_dict
return {k: v for k, v in args_dict.items() if v is not None}
def _execute_with_fallback(
self, method_name: str, decorators: list, *args, **kwargs
):
"""
Unified method execution with fallback support.
Execute method with fallback support.
Args:
method_name: Name of the raw method ('_raw_gen' or '_raw_gen_stream')
@@ -67,10 +75,10 @@ class BaseLLM(ABC):
return decorated_method()
except Exception as e:
if not self.fallback_llm:
logger.error(f"Primary LLM failed and no fallback available: {str(e)}")
logger.error(f"Primary LLM failed and no fallback configured: {str(e)}")
raise
logger.warning(
f"Falling back to {self.fallback_provider}/{self.fallback_model_name}. Error: {str(e)}"
f"Primary LLM failed. Falling back to {settings.FALLBACK_LLM_PROVIDER}/{settings.FALLBACK_LLM_NAME}. Error: {str(e)}"
)
fallback_method = getattr(

View File

@@ -1,5 +1,7 @@
import json
from openai import OpenAI
from application.core.settings import settings
from application.llm.base import BaseLLM
@@ -7,12 +9,11 @@ from application.llm.base import BaseLLM
class DocsGPTAPILLM(BaseLLM):
def __init__(self, api_key=None, user_api_key=None, *args, **kwargs):
from openai import OpenAI
super().__init__(*args, **kwargs)
self.client = OpenAI(api_key="sk-docsgpt-public", base_url="https://oai.arc53.com")
self.api_key = "sk-docsgpt-public"
self.client = OpenAI(api_key=self.api_key, base_url="https://oai.arc53.com")
self.user_api_key = user_api_key
self.api_key = api_key
def _clean_messages_openai(self, messages):
cleaned_messages = []
@@ -22,7 +23,6 @@ class DocsGPTAPILLM(BaseLLM):
if role == "model":
role = "assistant"
if role and content is not None:
if isinstance(content, str):
cleaned_messages.append({"role": role, "content": content})
@@ -33,14 +33,15 @@ class DocsGPTAPILLM(BaseLLM):
{"role": role, "content": item["text"]}
)
elif "function_call" in item:
cleaned_args = self._remove_null_values(
item["function_call"]["args"]
)
tool_call = {
"id": item["function_call"]["call_id"],
"type": "function",
"function": {
"name": item["function_call"]["name"],
"arguments": json.dumps(
item["function_call"]["args"]
),
"arguments": json.dumps(cleaned_args),
},
}
cleaned_messages.append(
@@ -68,7 +69,6 @@ class DocsGPTAPILLM(BaseLLM):
)
else:
raise ValueError(f"Unexpected content type: {type(content)}")
return cleaned_messages
def _raw_gen(
@@ -120,12 +120,19 @@ class DocsGPTAPILLM(BaseLLM):
response = self.client.chat.completions.create(
model="docsgpt", messages=messages, stream=stream, **kwargs
)
for line in response:
if len(line.choices) > 0 and line.choices[0].delta.content is not None and len(line.choices[0].delta.content) > 0:
yield line.choices[0].delta.content
elif len(line.choices) > 0:
yield line.choices[0]
try:
for line in response:
if (
len(line.choices) > 0
and line.choices[0].delta.content is not None
and len(line.choices[0].delta.content) > 0
):
yield line.choices[0].delta.content
elif len(line.choices) > 0:
yield line.choices[0]
finally:
if hasattr(response, "close"):
response.close()
def _supports_tools(self):
return True
return True

View File

@@ -13,8 +13,9 @@ from application.storage.storage_creator import StorageCreator
class GoogleLLM(BaseLLM):
def __init__(self, api_key=None, user_api_key=None, *args, **kwargs):
super().__init__(*args, **kwargs)
self.api_key = api_key
self.api_key = api_key or settings.GOOGLE_API_KEY or settings.API_KEY
self.user_api_key = user_api_key
self.client = genai.Client(api_key=self.api_key)
self.storage = StorageCreator.get_storage()
@@ -47,21 +48,19 @@ class GoogleLLM(BaseLLM):
"""
if not attachments:
return messages
prepared_messages = messages.copy()
# Find the user message to attach files to the last one
user_message_index = None
for i in range(len(prepared_messages) - 1, -1, -1):
if prepared_messages[i].get("role") == "user":
user_message_index = i
break
if user_message_index is None:
user_message = {"role": "user", "content": []}
prepared_messages.append(user_message)
user_message_index = len(prepared_messages) - 1
if isinstance(prepared_messages[user_message_index].get("content"), str):
text_content = prepared_messages[user_message_index]["content"]
prepared_messages[user_message_index]["content"] = [
@@ -69,7 +68,6 @@ class GoogleLLM(BaseLLM):
]
elif not isinstance(prepared_messages[user_message_index].get("content"), list):
prepared_messages[user_message_index]["content"] = []
files = []
for attachment in attachments:
mime_type = attachment.get("mime_type")
@@ -92,11 +90,9 @@ class GoogleLLM(BaseLLM):
"text": f"[File could not be processed: {attachment.get('path', 'unknown')}]",
}
)
if files:
logging.info(f"GoogleLLM: Adding {len(files)} files to message")
prepared_messages[user_message_index]["content"].append({"files": files})
return prepared_messages
def _upload_file_to_google(self, attachment):
@@ -111,14 +107,11 @@ class GoogleLLM(BaseLLM):
"""
if "google_file_uri" in attachment:
return attachment["google_file_uri"]
file_path = attachment.get("path")
if not file_path:
raise ValueError("No file path provided in attachment")
if not self.storage.file_exists(file_path):
raise FileNotFoundError(f"File not found: {file_path}")
try:
file_uri = self.storage.process_file(
file_path,
@@ -136,7 +129,6 @@ class GoogleLLM(BaseLLM):
attachments_collection.update_one(
{"_id": attachment["_id"]}, {"$set": {"google_file_uri": file_uri}}
)
return file_uri
except Exception as e:
logging.error(f"Error uploading file to Google AI: {e}", exc_info=True)
@@ -153,7 +145,6 @@ class GoogleLLM(BaseLLM):
role = "model"
elif role == "tool":
role = "model"
parts = []
if role and content is not None:
if isinstance(content, str):
@@ -163,10 +154,15 @@ class GoogleLLM(BaseLLM):
if "text" in item:
parts.append(types.Part.from_text(text=item["text"]))
elif "function_call" in item:
# Remove null values from args to avoid API errors
cleaned_args = self._remove_null_values(
item["function_call"]["args"]
)
parts.append(
types.Part.from_function_call(
name=item["function_call"]["name"],
args=item["function_call"]["args"],
args=cleaned_args,
)
)
elif "function_response" in item:
@@ -190,10 +186,8 @@ class GoogleLLM(BaseLLM):
)
else:
raise ValueError(f"Unexpected content type: {type(content)}")
if parts:
cleaned_messages.append(types.Content(role=role, parts=parts))
return cleaned_messages
def _clean_schema(self, schema_obj):
@@ -229,8 +223,8 @@ class GoogleLLM(BaseLLM):
cleaned[key] = [self._clean_schema(item) for item in value]
else:
cleaned[key] = value
# Validate that required properties actually exist in properties
if "required" in cleaned and "properties" in cleaned:
valid_required = []
properties_keys = set(cleaned["properties"].keys())
@@ -243,7 +237,6 @@ class GoogleLLM(BaseLLM):
cleaned.pop("required", None)
elif "required" in cleaned and "properties" not in cleaned:
cleaned.pop("required", None)
return cleaned
def _clean_tools_format(self, tools_list):
@@ -259,7 +252,6 @@ class GoogleLLM(BaseLLM):
cleaned_properties = {}
for k, v in properties.items():
cleaned_properties[k] = self._clean_schema(v)
genai_function = dict(
name=function["name"],
description=function["description"],
@@ -278,10 +270,8 @@ class GoogleLLM(BaseLLM):
name=function["name"],
description=function["description"],
)
genai_tool = types.Tool(function_declarations=[genai_function])
genai_tools.append(genai_tool)
return genai_tools
def _raw_gen(
@@ -303,16 +293,14 @@ class GoogleLLM(BaseLLM):
if messages[0].role == "system":
config.system_instruction = messages[0].parts[0].text
messages = messages[1:]
if tools:
cleaned_tools = self._clean_tools_format(tools)
config.tools = cleaned_tools
# Add response schema for structured output if provided
if response_schema:
config.response_schema = response_schema
config.response_mime_type = "application/json"
response = client.models.generate_content(
model=model,
contents=messages,
@@ -343,17 +331,16 @@ class GoogleLLM(BaseLLM):
if messages[0].role == "system":
config.system_instruction = messages[0].parts[0].text
messages = messages[1:]
if tools:
cleaned_tools = self._clean_tools_format(tools)
config.tools = cleaned_tools
# Add response schema for structured output if provided
if response_schema:
config.response_schema = response_schema
config.response_mime_type = "application/json"
# Check if we have both tools and file attachments
has_attachments = False
for message in messages:
for part in message.parts:
@@ -362,7 +349,6 @@ class GoogleLLM(BaseLLM):
break
if has_attachments:
break
logging.info(
f"GoogleLLM: Starting stream generation. Model: {model}, Messages: {json.dumps(messages, default=str)}, Has attachments: {has_attachments}"
)
@@ -373,17 +359,21 @@ class GoogleLLM(BaseLLM):
config=config,
)
for chunk in response:
if hasattr(chunk, "candidates") and chunk.candidates:
for candidate in chunk.candidates:
if candidate.content and candidate.content.parts:
for part in candidate.content.parts:
if part.function_call:
yield part
elif part.text:
yield part.text
elif hasattr(chunk, "text"):
yield chunk.text
try:
for chunk in response:
if hasattr(chunk, "candidates") and chunk.candidates:
for candidate in chunk.candidates:
if candidate.content and candidate.content.parts:
for part in candidate.content.parts:
if part.function_call:
yield part
elif part.text:
yield part.text
elif hasattr(chunk, "text"):
yield chunk.text
finally:
if hasattr(response, "close"):
response.close()
def _supports_tools(self):
"""Return whether this LLM supports function calling."""
@@ -397,7 +387,6 @@ class GoogleLLM(BaseLLM):
"""Convert JSON schema to Google AI structured output format."""
if not json_schema:
return None
type_map = {
"object": "OBJECT",
"array": "ARRAY",
@@ -410,12 +399,10 @@ class GoogleLLM(BaseLLM):
def convert(schema):
if not isinstance(schema, dict):
return schema
result = {}
schema_type = schema.get("type")
if schema_type:
result["type"] = type_map.get(schema_type.lower(), schema_type.upper())
for key in [
"description",
"nullable",
@@ -427,7 +414,6 @@ class GoogleLLM(BaseLLM):
]:
if key in schema:
result[key] = schema[key]
if "format" in schema:
format_value = schema["format"]
if schema_type == "string":
@@ -437,21 +423,17 @@ class GoogleLLM(BaseLLM):
result["format"] = format_value
else:
result["format"] = format_value
if "properties" in schema:
result["properties"] = {
k: convert(v) for k, v in schema["properties"].items()
}
if "propertyOrdering" not in result and result.get("type") == "OBJECT":
result["propertyOrdering"] = list(result["properties"].keys())
if "items" in schema:
result["items"] = convert(schema["items"])
for field in ["anyOf", "oneOf", "allOf"]:
if field in schema:
result[field] = [convert(s) for s in schema[field]]
return result
try:

View File

@@ -1,13 +1,18 @@
from application.llm.base import BaseLLM
from openai import OpenAI
from application.core.settings import settings
from application.llm.base import BaseLLM
class GroqLLM(BaseLLM):
def __init__(self, api_key=None, user_api_key=None, *args, **kwargs):
super().__init__(*args, **kwargs)
self.client = OpenAI(api_key=api_key, base_url="https://api.groq.com/openai/v1")
self.api_key = api_key
self.api_key = api_key or settings.GROQ_API_KEY or settings.API_KEY
self.user_api_key = user_api_key
self.client = OpenAI(
api_key=self.api_key, base_url="https://api.groq.com/openai/v1"
)
def _raw_gen(self, baseself, model, messages, stream=False, tools=None, **kwargs):
if tools:

View File

@@ -282,7 +282,7 @@ class LLMHandler(ABC):
messages = e.value
break
response = agent.llm.gen(
model=agent.gpt_model, messages=messages, tools=agent.tools
model=agent.model_id, messages=messages, tools=agent.tools
)
parsed = self.parse_response(response)
self.llm_calls.append(build_stack_data(agent.llm))
@@ -337,7 +337,7 @@ class LLMHandler(ABC):
tool_calls = {}
response = agent.llm.gen_stream(
model=agent.gpt_model, messages=messages, tools=agent.tools
model=agent.model_id, messages=messages, tools=agent.tools
)
self.llm_calls.append(build_stack_data(agent.llm))

View File

@@ -1,13 +1,17 @@
from application.llm.groq import GroqLLM
from application.llm.openai import OpenAILLM, AzureOpenAILLM
from application.llm.sagemaker import SagemakerAPILLM
from application.llm.huggingface import HuggingFaceLLM
from application.llm.llama_cpp import LlamaCpp
import logging
from application.llm.anthropic import AnthropicLLM
from application.llm.docsgpt_provider import DocsGPTAPILLM
from application.llm.premai import PremAILLM
from application.llm.google_ai import GoogleLLM
from application.llm.groq import GroqLLM
from application.llm.huggingface import HuggingFaceLLM
from application.llm.llama_cpp import LlamaCpp
from application.llm.novita import NovitaLLM
from application.llm.openai import AzureOpenAILLM, OpenAILLM
from application.llm.premai import PremAILLM
from application.llm.sagemaker import SagemakerAPILLM
logger = logging.getLogger(__name__)
class LLMCreator:
@@ -26,10 +30,26 @@ class LLMCreator:
}
@classmethod
def create_llm(cls, type, api_key, user_api_key, decoded_token, *args, **kwargs):
def create_llm(
cls, type, api_key, user_api_key, decoded_token, model_id=None, *args, **kwargs
):
from application.core.model_utils import get_base_url_for_model
llm_class = cls.llms.get(type.lower())
if not llm_class:
raise ValueError(f"No LLM class found for type {type}")
# Extract base_url from model configuration if model_id is provided
base_url = None
if model_id:
base_url = get_base_url_for_model(model_id)
return llm_class(
api_key, user_api_key, decoded_token=decoded_token, *args, **kwargs
api_key,
user_api_key,
decoded_token=decoded_token,
model_id=model_id,
base_url=base_url,
*args,
**kwargs,
)

View File

@@ -2,6 +2,8 @@ import base64
import json
import logging
from openai import OpenAI
from application.core.settings import settings
from application.llm.base import BaseLLM
from application.storage.storage_creator import StorageCreator
@@ -9,20 +11,25 @@ from application.storage.storage_creator import StorageCreator
class OpenAILLM(BaseLLM):
def __init__(self, api_key=None, user_api_key=None, *args, **kwargs):
from openai import OpenAI
def __init__(self, api_key=None, user_api_key=None, base_url=None, *args, **kwargs):
super().__init__(*args, **kwargs)
if (
self.api_key = api_key or settings.OPENAI_API_KEY or settings.API_KEY
self.user_api_key = user_api_key
# Priority: 1) Parameter base_url, 2) Settings OPENAI_BASE_URL, 3) Default
effective_base_url = None
if base_url and isinstance(base_url, str) and base_url.strip():
effective_base_url = base_url
elif (
isinstance(settings.OPENAI_BASE_URL, str)
and settings.OPENAI_BASE_URL.strip()
):
self.client = OpenAI(api_key=api_key, base_url=settings.OPENAI_BASE_URL)
effective_base_url = settings.OPENAI_BASE_URL
else:
DEFAULT_OPENAI_API_BASE = "https://api.openai.com/v1"
self.client = OpenAI(api_key=api_key, base_url=DEFAULT_OPENAI_API_BASE)
self.api_key = api_key
self.user_api_key = user_api_key
effective_base_url = "https://api.openai.com/v1"
self.client = OpenAI(api_key=self.api_key, base_url=effective_base_url)
self.storage = StorageCreator.get_storage()
def _clean_messages_openai(self, messages):
@@ -33,7 +40,6 @@ class OpenAILLM(BaseLLM):
if role == "model":
role = "assistant"
if role and content is not None:
if isinstance(content, str):
cleaned_messages.append({"role": role, "content": content})
@@ -44,14 +50,15 @@ class OpenAILLM(BaseLLM):
{"role": role, "content": item["text"]}
)
elif "function_call" in item:
cleaned_args = self._remove_null_values(
item["function_call"]["args"]
)
tool_call = {
"id": item["function_call"]["call_id"],
"type": "function",
"function": {
"name": item["function_call"]["name"],
"arguments": json.dumps(
item["function_call"]["args"]
),
"arguments": json.dumps(cleaned_args),
},
}
cleaned_messages.append(
@@ -106,7 +113,6 @@ class OpenAILLM(BaseLLM):
)
else:
raise ValueError(f"Unexpected content type: {type(content)}")
return cleaned_messages
def _raw_gen(
@@ -131,10 +137,8 @@ class OpenAILLM(BaseLLM):
if tools:
request_params["tools"] = tools
if response_format:
request_params["response_format"] = response_format
response = self.client.chat.completions.create(**request_params)
if tools:
@@ -164,21 +168,23 @@ class OpenAILLM(BaseLLM):
if tools:
request_params["tools"] = tools
if response_format:
request_params["response_format"] = response_format
response = self.client.chat.completions.create(**request_params)
for line in response:
if (
len(line.choices) > 0
and line.choices[0].delta.content is not None
and len(line.choices[0].delta.content) > 0
):
yield line.choices[0].delta.content
elif len(line.choices) > 0:
yield line.choices[0]
try:
for line in response:
if (
len(line.choices) > 0
and line.choices[0].delta.content is not None
and len(line.choices[0].delta.content) > 0
):
yield line.choices[0].delta.content
elif len(line.choices) > 0:
yield line.choices[0]
finally:
if hasattr(response, "close"):
response.close()
def _supports_tools(self):
return True
@@ -189,7 +195,6 @@ class OpenAILLM(BaseLLM):
def prepare_structured_output_format(self, json_schema):
if not json_schema:
return None
try:
def add_additional_properties_false(schema_obj):
@@ -199,11 +204,11 @@ class OpenAILLM(BaseLLM):
if schema_copy.get("type") == "object":
schema_copy["additionalProperties"] = False
# Ensure 'required' includes all properties for OpenAI strict mode
if "properties" in schema_copy:
schema_copy["required"] = list(
schema_copy["properties"].keys()
)
for key, value in schema_copy.items():
if key == "properties" and isinstance(value, dict):
schema_copy[key] = {
@@ -219,7 +224,6 @@ class OpenAILLM(BaseLLM):
add_additional_properties_false(sub_schema)
for sub_schema in value
]
return schema_copy
return schema_obj
@@ -238,7 +242,6 @@ class OpenAILLM(BaseLLM):
}
return result
except Exception as e:
logging.error(f"Error preparing structured output format: {e}")
return None
@@ -272,21 +275,19 @@ class OpenAILLM(BaseLLM):
"""
if not attachments:
return messages
prepared_messages = messages.copy()
# Find the user message to attach file_id to the last one
user_message_index = None
for i in range(len(prepared_messages) - 1, -1, -1):
if prepared_messages[i].get("role") == "user":
user_message_index = i
break
if user_message_index is None:
user_message = {"role": "user", "content": []}
prepared_messages.append(user_message)
user_message_index = len(prepared_messages) - 1
if isinstance(prepared_messages[user_message_index].get("content"), str):
text_content = prepared_messages[user_message_index]["content"]
prepared_messages[user_message_index]["content"] = [
@@ -294,7 +295,6 @@ class OpenAILLM(BaseLLM):
]
elif not isinstance(prepared_messages[user_message_index].get("content"), list):
prepared_messages[user_message_index]["content"] = []
for attachment in attachments:
mime_type = attachment.get("mime_type")
@@ -321,6 +321,7 @@ class OpenAILLM(BaseLLM):
}
)
# Handle PDFs using the file API
elif mime_type == "application/pdf":
try:
file_id = self._upload_file_to_openai(attachment)
@@ -336,7 +337,6 @@ class OpenAILLM(BaseLLM):
"text": f"File content:\n\n{attachment['content']}",
}
)
return prepared_messages
def _get_base64_image(self, attachment):
@@ -352,7 +352,6 @@ class OpenAILLM(BaseLLM):
file_path = attachment.get("path")
if not file_path:
raise ValueError("No file path provided in attachment")
try:
with self.storage.get_file(file_path) as image_file:
return base64.b64encode(image_file.read()).decode("utf-8")
@@ -376,12 +375,10 @@ class OpenAILLM(BaseLLM):
if "openai_file_id" in attachment:
return attachment["openai_file_id"]
file_path = attachment.get("path")
if not self.storage.file_exists(file_path):
raise FileNotFoundError(f"File not found: {file_path}")
try:
file_id = self.storage.process_file(
file_path,
@@ -399,7 +396,6 @@ class OpenAILLM(BaseLLM):
attachments_collection.update_one(
{"_id": attachment["_id"]}, {"$set": {"openai_file_id": file_id}}
)
return file_id
except Exception as e:
logging.error(f"Error uploading file to OpenAI: {e}", exc_info=True)

View File

@@ -1,5 +1,6 @@
import os
import logging
from typing import List, Any
from retry import retry
from tqdm import tqdm
from application.core.settings import settings
@@ -22,13 +23,16 @@ def sanitize_content(content: str) -> str:
@retry(tries=10, delay=60)
def add_text_to_store_with_retry(store, doc, source_id):
"""
Add a document's text and metadata to the vector store with retry logic.
def add_text_to_store_with_retry(store: Any, doc: Any, source_id: str) -> None:
"""Add a document's text and metadata to the vector store with retry logic.
Args:
store: The vector store object.
doc: The document to be added.
source_id: Unique identifier for the source.
Raises:
Exception: If document addition fails after all retry attempts.
"""
try:
# Sanitize content to remove NUL characters that cause ingestion failures
@@ -41,18 +45,21 @@ def add_text_to_store_with_retry(store, doc, source_id):
raise
def embed_and_store_documents(docs, folder_name, source_id, task_status):
"""
Embeds documents and stores them in a vector store.
def embed_and_store_documents(docs: List[Any], folder_name: str, source_id: str, task_status: Any) -> None:
"""Embeds documents and stores them in a vector store.
Args:
docs (list): List of documents to be embedded and stored.
folder_name (str): Directory to save the vector store.
source_id (str): Unique identifier for the source.
docs: List of documents to be embedded and stored.
folder_name: Directory to save the vector store.
source_id: Unique identifier for the source.
task_status: Task state manager for progress updates.
Returns:
None
Raises:
OSError: If unable to create folder or save vector store.
Exception: If vector store creation or document embedding fails.
"""
# Ensure the folder exists
if not os.path.exists(folder_name):
@@ -95,10 +102,21 @@ def embed_and_store_documents(docs, folder_name, source_id, task_status):
except Exception as e:
logging.error(f"Error embedding document {idx}: {e}", exc_info=True)
logging.info(f"Saving progress at document {idx} out of {total_docs}")
store.save_local(folder_name)
try:
store.save_local(folder_name)
logging.info("Progress saved successfully")
except Exception as save_error:
logging.error(f"CRITICAL: Failed to save progress: {save_error}", exc_info=True)
# Continue without breaking to attempt final save
break
# Save the vector store
if settings.VECTOR_STORE == "faiss":
store.save_local(folder_name)
logging.info("Vector store saved successfully.")
try:
store.save_local(folder_name)
logging.info("Vector store saved successfully.")
except Exception as e:
logging.error(f"CRITICAL: Failed to save final vector store: {e}", exc_info=True)
raise OSError(f"Unable to save vector store to {folder_name}: {e}") from e
else:
logging.info("Vector store saved successfully.")

View File

@@ -1,44 +1,135 @@
import base64
import requests
from typing import List
import time
from typing import List, Optional
from application.parser.remote.base import BaseRemote
from langchain_core.documents import Document
from application.parser.schema.base import Document
import mimetypes
from application.core.settings import settings
class GitHubLoader(BaseRemote):
def __init__(self):
self.access_token = None
self.access_token = settings.GITHUB_ACCESS_TOKEN
self.headers = {
"Authorization": f"token {self.access_token}"
} if self.access_token else {}
"Authorization": f"token {self.access_token}",
"Accept": "application/vnd.github.v3+json"
} if self.access_token else {
"Accept": "application/vnd.github.v3+json"
}
return
def fetch_file_content(self, repo_url: str, file_path: str) -> str:
def is_text_file(self, file_path: str) -> bool:
"""Determine if a file is a text file based on extension."""
# Common text file extensions
text_extensions = {
'.txt', '.md', '.markdown', '.rst', '.json', '.xml', '.yaml', '.yml',
'.py', '.js', '.ts', '.jsx', '.tsx', '.java', '.c', '.cpp', '.h', '.hpp',
'.cs', '.go', '.rs', '.rb', '.php', '.swift', '.kt', '.scala',
'.html', '.css', '.scss', '.sass', '.less',
'.sh', '.bash', '.zsh', '.fish',
'.sql', '.r', '.m', '.mat',
'.ini', '.cfg', '.conf', '.config', '.env',
'.gitignore', '.dockerignore', '.editorconfig',
'.log', '.csv', '.tsv'
}
# Get file extension
file_lower = file_path.lower()
for ext in text_extensions:
if file_lower.endswith(ext):
return True
# Also check MIME type
mime_type, _ = mimetypes.guess_type(file_path)
if mime_type and (mime_type.startswith("text") or mime_type in ["application/json", "application/xml"]):
return True
return False
def fetch_file_content(self, repo_url: str, file_path: str) -> Optional[str]:
"""Fetch file content. Returns None if file should be skipped (binary files or empty files)."""
url = f"https://api.github.com/repos/{repo_url}/contents/{file_path}"
response = requests.get(url, headers=self.headers)
response = self._make_request(url)
if response.status_code == 200:
content = response.json()
mime_type, _ = mimetypes.guess_type(file_path) # Guess the MIME type based on the file extension
content = response.json()
if content.get("encoding") == "base64":
if mime_type and mime_type.startswith("text"): # Handle only text files
try:
decoded_content = base64.b64decode(content["content"]).decode("utf-8")
return f"Filename: {file_path}\n\n{decoded_content}"
except Exception as e:
raise e
else:
return f"Filename: {file_path} is a binary file and was skipped."
if content.get("encoding") == "base64":
if self.is_text_file(file_path): # Handle only text files
try:
decoded_content = base64.b64decode(content["content"]).decode("utf-8").strip()
# Skip empty files
if not decoded_content:
return None
return decoded_content
except Exception:
# If decoding fails, it's probably a binary file
return None
else:
return f"Filename: {file_path}\n\n{content['content']}"
# Skip binary files by returning None
return None
else:
response.raise_for_status()
file_content = content['content'].strip()
# Skip empty files
if not file_content:
return None
return file_content
def _make_request(self, url: str, max_retries: int = 3) -> requests.Response:
"""Make a request with retry logic for rate limiting"""
for attempt in range(max_retries):
response = requests.get(url, headers=self.headers)
if response.status_code == 200:
return response
elif response.status_code == 403:
# Check if it's a rate limit issue
try:
error_data = response.json()
error_msg = error_data.get("message", "")
# Check rate limit headers
remaining = response.headers.get("X-RateLimit-Remaining", "unknown")
reset_time = response.headers.get("X-RateLimit-Reset", "unknown")
print(f"GitHub API 403 Error: {error_msg}")
print(f"Rate limit remaining: {remaining}, Reset time: {reset_time}")
if "rate limit" in error_msg.lower():
if attempt < max_retries - 1:
wait_time = 2 ** attempt # Exponential backoff
print(f"Rate limit hit, waiting {wait_time} seconds before retry...")
time.sleep(wait_time)
continue
# Provide helpful error message
if remaining == "0":
raise Exception(f"GitHub API rate limit exceeded. Please set GITHUB_ACCESS_TOKEN environment variable. Reset time: {reset_time}")
else:
raise Exception(f"GitHub API error: {error_msg}. This may require authentication - set GITHUB_ACCESS_TOKEN environment variable.")
except Exception as e:
if isinstance(e, Exception) and "GitHub API" in str(e):
raise
# If we can't parse the response, raise the original error
response.raise_for_status()
else:
response.raise_for_status()
return response
def fetch_repo_files(self, repo_url: str, path: str = "") -> List[str]:
url = f"https://api.github.com/repos/{repo_url}/contents/{path}"
response = requests.get(url, headers={**self.headers, "Accept": "application/vnd.github.v3.raw"})
response = self._make_request(url)
contents = response.json()
# Handle error responses from GitHub API
if isinstance(contents, dict) and "message" in contents:
raise Exception(f"GitHub API error: {contents.get('message')}")
# Ensure contents is a list
if not isinstance(contents, list):
raise TypeError(f"Expected list from GitHub API, got {type(contents).__name__}: {contents}")
files = []
for item in contents:
if item["type"] == "file":
@@ -53,6 +144,15 @@ class GitHubLoader(BaseRemote):
documents = []
for file_path in files:
content = self.fetch_file_content(repo_name, file_path)
documents.append(Document(page_content=content, metadata={"title": file_path,
"source": f"https://github.com/{repo_name}/blob/main/{file_path}"}))
# Skip binary files (content is None)
if content is None:
continue
documents.append(Document(
text=content,
doc_id=file_path,
extra_info={
"title": file_path,
"source": f"https://github.com/{repo_name}/blob/main/{file_path}"
}
))
return documents

View File

@@ -10,6 +10,7 @@ ebooklib==0.18
escodegen==1.0.11
esprima==4.0.1
esutils==1.0.1
elevenlabs==2.17.0
Flask==3.1.1
faiss-cpu==1.9.0.post1
fastmcp==2.11.0

View File

@@ -8,7 +8,3 @@ class BaseRetriever(ABC):
@abstractmethod
def search(self, *args, **kwargs):
pass
@abstractmethod
def get_params(self):
pass

View File

@@ -4,7 +4,7 @@ import os
from application.core.settings import settings
from application.llm.llm_creator import LLMCreator
from application.retriever.base import BaseRetriever
from application.utils import num_tokens_from_string
from application.vectorstore.vector_creator import VectorCreator
@@ -15,14 +15,13 @@ class ClassicRAG(BaseRetriever):
chat_history=None,
prompt="",
chunks=2,
token_limit=150,
gpt_model="docsgpt",
doc_token_limit=50000,
model_id="docsgpt-local",
user_api_key=None,
llm_name=settings.LLM_PROVIDER,
api_key=settings.API_KEY,
decoded_token=None,
):
"""Initialize ClassicRAG retriever with vectorstore sources and LLM configuration"""
self.original_question = source.get("question", "")
self.chat_history = chat_history if chat_history is not None else []
self.prompt = prompt
@@ -41,17 +40,8 @@ class ClassicRAG(BaseRetriever):
f"ClassicRAG initialized with chunks={self.chunks}, user_api_key={user_identifier}, "
f"sources={'active_docs' in source and source['active_docs'] is not None}"
)
self.gpt_model = gpt_model
self.token_limit = (
token_limit
if token_limit
< settings.LLM_TOKEN_LIMITS.get(
self.gpt_model, settings.DEFAULT_MAX_HISTORY
)
else settings.LLM_TOKEN_LIMITS.get(
self.gpt_model, settings.DEFAULT_MAX_HISTORY
)
)
self.model_id = model_id
self.doc_token_limit = doc_token_limit
self.user_api_key = user_api_key
self.llm_name = llm_name
self.api_key = api_key
@@ -110,7 +100,7 @@ class ClassicRAG(BaseRetriever):
]
try:
rephrased_query = self.llm.gen(model=self.gpt_model, messages=messages)
rephrased_query = self.llm.gen(model=self.model_id, messages=messages)
print(f"Rephrased query: {rephrased_query}")
return rephrased_query if rephrased_query else self.original_question
except Exception as e:
@@ -118,21 +108,17 @@ class ClassicRAG(BaseRetriever):
return self.original_question
def _get_data(self):
"""Retrieve relevant documents from configured vectorstores"""
if self.chunks == 0 or not self.vectorstores:
logging.info(
f"ClassicRAG._get_data: Skipping retrieval - chunks={self.chunks}, "
f"vectorstores_count={len(self.vectorstores) if self.vectorstores else 0}"
)
return []
all_docs = []
chunks_per_source = max(1, self.chunks // len(self.vectorstores))
logging.info(
f"ClassicRAG._get_data: Starting retrieval with chunks={self.chunks}, "
f"vectorstores={self.vectorstores}, chunks_per_source={chunks_per_source}, "
f"query='{self.question[:50]}...'"
)
token_budget = max(int(self.doc_token_limit * 0.9), 100)
cumulative_tokens = 0
for vectorstore_id in self.vectorstores:
if vectorstore_id:
@@ -140,15 +126,21 @@ class ClassicRAG(BaseRetriever):
docsearch = VectorCreator.create_vectorstore(
settings.VECTOR_STORE, vectorstore_id, settings.EMBEDDINGS_KEY
)
docs_temp = docsearch.search(self.question, k=chunks_per_source)
docs_temp = docsearch.search(
self.question, k=max(chunks_per_source * 2, 20)
)
for doc in docs_temp:
if cumulative_tokens >= token_budget:
break
if hasattr(doc, "page_content") and hasattr(doc, "metadata"):
page_content = doc.page_content
metadata = doc.metadata
else:
page_content = doc.get("text", doc.get("page_content", ""))
metadata = doc.get("metadata", {})
title = metadata.get(
"title", metadata.get("post_title", page_content)
)
@@ -168,23 +160,35 @@ class ClassicRAG(BaseRetriever):
if not filename:
filename = title
source_path = metadata.get("source") or vectorstore_id
all_docs.append(
{
"title": title,
"text": page_content,
"source": source_path,
"filename": filename,
}
)
doc_text_with_header = f"{filename}\n{page_content}"
doc_tokens = num_tokens_from_string(doc_text_with_header)
if cumulative_tokens + doc_tokens < token_budget:
all_docs.append(
{
"title": title,
"text": page_content,
"source": source_path,
"filename": filename,
}
)
cumulative_tokens += doc_tokens
if cumulative_tokens >= token_budget:
break
except Exception as e:
logging.error(
f"Error searching vectorstore {vectorstore_id}: {e}",
exc_info=True,
)
continue
logging.info(
f"ClassicRAG._get_data: Retrieval complete - retrieved {len(all_docs)} documents "
f"(requested chunks={self.chunks}, chunks_per_source={chunks_per_source})"
f"(requested chunks={self.chunks}, chunks_per_source={chunks_per_source}, "
f"cumulative_tokens={cumulative_tokens}/{token_budget})"
)
return all_docs
@@ -194,15 +198,3 @@ class ClassicRAG(BaseRetriever):
self.original_question = query
self.question = self._rephrase_query()
return self._get_data()
def get_params(self):
"""Return current retriever configuration parameters"""
return {
"question": self.original_question,
"rephrased_question": self.question,
"sources": self.vectorstores,
"chunks": self.chunks,
"token_limit": self.token_limit,
"gpt_model": self.gpt_model,
"user_api_key": self.user_api_key,
}

View File

@@ -0,0 +1,26 @@
import click
from application.core.mongo_db import MongoDB
from application.core.settings import settings
from application.seed.seeder import DatabaseSeeder
@click.group()
def seed():
"""Database seeding commands"""
pass
@seed.command()
@click.option("--force", is_flag=True, help="Force reseeding even if data exists")
def init(force):
"""Initialize database with seed data"""
mongo = MongoDB.get_client()
db = mongo[settings.MONGO_DB_NAME]
seeder = DatabaseSeeder(db)
seeder.seed_initial_data(force=force)
if __name__ == "__main__":
seed()

View File

@@ -0,0 +1,36 @@
# Configuration for Premade Agents
# This file contains template agents that will be seeded into the database
agents:
# Basic Agent Template
- name: "Agent Name" # Required: Unique name for the agent
description: "What this agent does" # Required: Brief description of the agent's purpose
image: "URL_TO_IMAGE" # Optional: URL to agent's avatar/image
agent_type: "classic" # Required: Type of agent (e.g., classic, react, etc.)
prompt_id: "default" # Optional: Reference to prompt template
prompt: # Optional: Define new prompt
name: "New Prompt"
content: "You are new agent with cool new prompt."
chunks: "0" # Optional: Chunking strategy for documents
retriever: "" # Optional: Retriever type for document search
# Source Configuration (where the agent gets its knowledge)
source: # Optional: Select a source to link with agent
name: "Source Display Name" # Human-readable name for the source
url: "https://example.com/data-source" # URL or path to knowledge source
loader: "url" # Type of loader (url, pdf, txt, etc.)
# Tools Configuration (what capabilities the agent has)
tools: # Optional: Remove if agent doesn't need tools
- name: "tool_name" # Must match a supported tool name
display_name: "Tool Display Name" # Optional: Human-readable name for the tool
config:
# Tool-specific configuration
# Example for DuckDuckGo:
# token: "${DDG_API_KEY}" # ${} denotes environment variable
# Add more tools as needed
# - name: "another_tool"
# config:
# param1: "value1"
# param2: "${ENV_VAR}"

View File

@@ -0,0 +1,94 @@
# Configuration for Premade Agents
agents:
- name: "Assistant"
description: "Your general-purpose AI assistant. Ready to help with a wide range of tasks."
image: "https://d3dg1063dc54p9.cloudfront.net/imgs/agents/agent-logo.svg"
agent_type: "classic"
prompt_id: "default"
chunks: "0"
retriever: ""
# Tools Configuration
tools:
- name: "tool_name"
display_name: "read_webpage"
config:
- name: "Researcher"
description: "A specialized research agent that performs deep dives into subjects."
image: "https://d3dg1063dc54p9.cloudfront.net/imgs/agents/agent-researcher.svg"
agent_type: "react"
prompt:
name: "Researcher-Agent"
content: |
You are a specialized AI research assistant, DocsGPT. Your primary function is to conduct in-depth research on a given subject or question. You are methodical, thorough, and analytical. You should perform multiple iterations of thinking to gather and synthesize information before providing a final, comprehensive answer.
You have access to the 'Read Webpage' tool. Use this tool to explore sources, gather data, and deepen your understanding. Be proactive in using the tool to fill in knowledge gaps and validate information.
Users can Upload documents for your context as attachments or sources via UI using the Conversation input box.
If appropriate, your answers can include code examples, formatted as follows:
```(language)
(code)
```
Users are also able to see charts and diagrams if you use them with valid mermaid syntax in your responses. Try to respond with mermaid charts if visualization helps with users queries. You effectively utilize chat history, ensuring relevant and tailored responses. Try to use additional provided context if it's available, otherwise use your knowledge and tool capabilities.
----------------
Possible additional context from uploaded sources:
{summaries}
chunks: "0"
retriever: ""
# Tools Configuration
tools:
- name: "tool_name"
display_name: "read_webpage"
config:
- name: "Search Widget"
description: "A powerful search widget agent. Ask it anything about DocsGPT"
image: "https://d3dg1063dc54p9.cloudfront.net/imgs/agents/agent-search.svg"
agent_type: "classic"
prompt:
name: "Search-Agent"
content: |
You are a website search assistant, DocsGPT. Your sole purpose is to help users find information within the provided context of the DocsGPT documentation. Act as a specialized search engine.
Your answers must be based *only* on the provided context. Do not use any external knowledge. If the answer is not in the context, inform the user that you could not find the information within the documentation.
Keep your responses concise and directly related to the user's query, pointing them to the most relevant information.
----------------
Possible additional context from uploaded sources:
{summaries}
chunks: "8"
retriever: ""
source:
name: "DocsGPT-Docs"
url: "https://d3dg1063dc54p9.cloudfront.net/agent-source/docsgpt-documentation.md" # URL to DocsGPT documentation
loader: "url"
- name: "Support Widget"
description: "A friendly support widget agent to help you with any questions."
image: "https://d3dg1063dc54p9.cloudfront.net/imgs/agents/agent-support.svg"
agent_type: "classic"
prompt:
name: "Support-Agent"
content: |
You are a helpful AI support widget agent, DocsGPT. Your goal is to assist users by answering their questions about our website, product and its features. Provide friendly, clear, and direct support.
Your knowledge is strictly limited to the provided context from the DocsGPT documentation. You must not answer questions outside of this scope. If a user asks something you cannot answer from the context, politely state that you can only help with questions about this website.
Effectively utilize chat history to understand the user's issue fully. Guide users to the information they need in a helpful and conversational manner.
----------------
Possible additional context from uploaded sources:
{summaries}
chunks: "8"
retriever: ""
source:
name: "DocsGPT-Docs"
url: "https://d3dg1063dc54p9.cloudfront.net/agent-source/docsgpt-documentation.md" # URL to DocsGPT documentation
loader: "url"

277
application/seed/seeder.py Normal file
View File

@@ -0,0 +1,277 @@
import logging
import os
from datetime import datetime, timezone
from typing import Dict, List, Optional, Union
import yaml
from bson import ObjectId
from bson.dbref import DBRef
from dotenv import load_dotenv
from pymongo import MongoClient
from application.agents.tools.tool_manager import ToolManager
from application.api.user.tasks import ingest_remote
load_dotenv()
tool_config = {}
tool_manager = ToolManager(config=tool_config)
class DatabaseSeeder:
def __init__(self, db):
self.db = db
self.tools_collection = self.db["user_tools"]
self.sources_collection = self.db["sources"]
self.agents_collection = self.db["agents"]
self.prompts_collection = self.db["prompts"]
self.system_user_id = "system"
self.logger = logging.getLogger(__name__)
def seed_initial_data(self, config_path: str = None, force=False):
"""Main entry point for seeding all initial data"""
if not force and self._is_already_seeded():
self.logger.info("Database already seeded. Use force=True to reseed.")
return
config_path = config_path or os.path.join(
os.path.dirname(__file__), "config", "premade_agents.yaml"
)
try:
with open(config_path, "r") as f:
config = yaml.safe_load(f)
self._seed_from_config(config)
except Exception as e:
self.logger.error(f"Failed to load seeding config: {str(e)}")
raise
def _seed_from_config(self, config: Dict):
"""Seed all data from configuration"""
self.logger.info("🌱 Starting seeding...")
if not config.get("agents"):
self.logger.warning("No agents found in config")
return
used_tool_ids = set()
for agent_config in config["agents"]:
try:
self.logger.info(f"Processing agent: {agent_config['name']}")
# 1. Handle Source
source_result = self._handle_source(agent_config)
if source_result is False:
self.logger.error(
f"Skipping agent {agent_config['name']} due to source ingestion failure"
)
continue
source_id = source_result
# 2. Handle Tools
tool_ids = self._handle_tools(agent_config)
if len(tool_ids) == 0:
self.logger.warning(
f"No valid tools for agent {agent_config['name']}"
)
used_tool_ids.update(tool_ids)
# 3. Handle Prompt
prompt_id = self._handle_prompt(agent_config)
# 4. Create Agent
agent_data = {
"user": self.system_user_id,
"name": agent_config["name"],
"description": agent_config["description"],
"image": agent_config.get("image", ""),
"source": (
DBRef("sources", ObjectId(source_id)) if source_id else ""
),
"tools": [str(tid) for tid in tool_ids],
"agent_type": agent_config["agent_type"],
"prompt_id": prompt_id or agent_config.get("prompt_id", "default"),
"chunks": agent_config.get("chunks", "0"),
"retriever": agent_config.get("retriever", ""),
"status": "template",
"createdAt": datetime.now(timezone.utc),
"updatedAt": datetime.now(timezone.utc),
}
existing = self.agents_collection.find_one(
{"user": self.system_user_id, "name": agent_config["name"]}
)
if existing:
self.logger.info(f"Updating existing agent: {agent_config['name']}")
self.agents_collection.update_one(
{"_id": existing["_id"]}, {"$set": agent_data}
)
agent_id = existing["_id"]
else:
self.logger.info(f"Creating new agent: {agent_config['name']}")
result = self.agents_collection.insert_one(agent_data)
agent_id = result.inserted_id
self.logger.info(
f"Successfully processed agent: {agent_config['name']} (ID: {agent_id})"
)
except Exception as e:
self.logger.error(
f"Error processing agent {agent_config['name']}: {str(e)}"
)
continue
self.logger.info("✅ Database seeding completed")
def _handle_source(self, agent_config: Dict) -> Union[ObjectId, None, bool]:
"""Handle source ingestion and return source ID"""
if not agent_config.get("source"):
self.logger.info(
"No source provided for agent - will create agent without source"
)
return None
source_config = agent_config["source"]
self.logger.info(f"Ingesting source: {source_config['url']}")
try:
existing = self.sources_collection.find_one(
{"user": self.system_user_id, "remote_data": source_config["url"]}
)
if existing:
self.logger.info(f"Source already exists: {existing['_id']}")
return existing["_id"]
# Ingest new source using worker
task = ingest_remote.delay(
source_data=source_config["url"],
job_name=source_config["name"],
user=self.system_user_id,
loader=source_config.get("loader", "url"),
)
result = task.get(timeout=300)
if not task.successful():
raise Exception(f"Source ingestion failed: {result}")
source_id = None
if isinstance(result, dict) and "id" in result:
source_id = result["id"]
else:
raise Exception(f"Source ingestion result missing 'id': {result}")
self.logger.info(f"Source ingested successfully: {source_id}")
return source_id
except Exception as e:
self.logger.error(f"Failed to ingest source: {str(e)}")
return False
def _handle_tools(self, agent_config: Dict) -> List[ObjectId]:
"""Handle tool creation and return list of tool IDs"""
tool_ids = []
if not agent_config.get("tools"):
return tool_ids
for tool_config in agent_config["tools"]:
try:
tool_name = tool_config["name"]
processed_config = self._process_config(tool_config.get("config", {}))
self.logger.info(f"Processing tool: {tool_name}")
existing = self.tools_collection.find_one(
{
"user": self.system_user_id,
"name": tool_name,
"config": processed_config,
}
)
if existing:
self.logger.info(f"Tool already exists: {existing['_id']}")
tool_ids.append(existing["_id"])
continue
tool_data = {
"user": self.system_user_id,
"name": tool_name,
"displayName": tool_config.get("display_name", tool_name),
"description": tool_config.get("description", ""),
"actions": tool_manager.tools[tool_name].get_actions_metadata(),
"config": processed_config,
"status": True,
}
result = self.tools_collection.insert_one(tool_data)
tool_ids.append(result.inserted_id)
self.logger.info(f"Created new tool: {result.inserted_id}")
except Exception as e:
self.logger.error(f"Failed to process tool {tool_name}: {str(e)}")
continue
return tool_ids
def _handle_prompt(self, agent_config: Dict) -> Optional[str]:
"""Handle prompt creation and return prompt ID"""
if not agent_config.get("prompt"):
return None
prompt_config = agent_config["prompt"]
prompt_name = prompt_config.get("name", f"{agent_config['name']} Prompt")
prompt_content = prompt_config.get("content", "")
if not prompt_content:
self.logger.warning(
f"No prompt content provided for agent {agent_config['name']}"
)
return None
self.logger.info(f"Processing prompt: {prompt_name}")
try:
existing = self.prompts_collection.find_one(
{
"user": self.system_user_id,
"name": prompt_name,
"content": prompt_content,
}
)
if existing:
self.logger.info(f"Prompt already exists: {existing['_id']}")
return str(existing["_id"])
prompt_data = {
"name": prompt_name,
"content": prompt_content,
"user": self.system_user_id,
}
result = self.prompts_collection.insert_one(prompt_data)
prompt_id = str(result.inserted_id)
self.logger.info(f"Created new prompt: {prompt_id}")
return prompt_id
except Exception as e:
self.logger.error(f"Failed to process prompt {prompt_name}: {str(e)}")
return None
def _process_config(self, config: Dict) -> Dict:
"""Process config values to replace environment variables"""
processed = {}
for key, value in config.items():
if (
isinstance(value, str)
and value.startswith("${")
and value.endswith("}")
):
env_var = value[2:-1]
processed[key] = os.getenv(env_var, "")
else:
processed[key] = value
return processed
def _is_already_seeded(self) -> bool:
"""Check if premade agents already exist"""
return self.agents_collection.count_documents({"user": self.system_user_id}) > 0
@classmethod
def initialize_from_env(cls, worker=None):
"""Factory method to create seeder from environment"""
mongo_uri = os.getenv("MONGO_URI", "mongodb://localhost:27017")
db_name = os.getenv("MONGO_DB_NAME", "docsgpt")
client = MongoClient(mongo_uri)
db = client[db_name]
return cls(db)

View File

View File

@@ -0,0 +1,190 @@
import logging
import uuid
from abc import ABC, abstractmethod
from datetime import datetime, timezone
from typing import Any, Dict, Optional
logger = logging.getLogger(__name__)
class NamespaceBuilder(ABC):
"""Base class for building template context namespaces"""
@abstractmethod
def build(self, **kwargs) -> Dict[str, Any]:
"""Build namespace context dictionary"""
pass
@property
@abstractmethod
def namespace_name(self) -> str:
"""Name of this namespace for template access"""
pass
class SystemNamespace(NamespaceBuilder):
"""System metadata namespace: {{ system.* }}"""
@property
def namespace_name(self) -> str:
return "system"
def build(
self, request_id: Optional[str] = None, user_id: Optional[str] = None, **kwargs
) -> Dict[str, Any]:
"""
Build system context with metadata.
Args:
request_id: Unique request identifier
user_id: Current user identifier
Returns:
Dictionary with system variables
"""
now = datetime.now(timezone.utc)
return {
"date": now.strftime("%Y-%m-%d"),
"time": now.strftime("%H:%M:%S"),
"timestamp": now.isoformat(),
"request_id": request_id or str(uuid.uuid4()),
"user_id": user_id,
}
class PassthroughNamespace(NamespaceBuilder):
"""Request parameters namespace: {{ passthrough.* }}"""
@property
def namespace_name(self) -> str:
return "passthrough"
def build(
self, passthrough_data: Optional[Dict[str, Any]] = None, **kwargs
) -> Dict[str, Any]:
"""
Build passthrough context from request parameters.
Args:
passthrough_data: Dictionary of parameters from web request
Returns:
Dictionary with passthrough variables
"""
if not passthrough_data:
return {}
safe_data = {}
for key, value in passthrough_data.items():
if isinstance(value, (str, int, float, bool, type(None))):
safe_data[key] = value
else:
logger.warning(
f"Skipping non-serializable passthrough value for key '{key}': {type(value)}"
)
return safe_data
class SourceNamespace(NamespaceBuilder):
"""RAG source documents namespace: {{ source.* }}"""
@property
def namespace_name(self) -> str:
return "source"
def build(
self, docs: Optional[list] = None, docs_together: Optional[str] = None, **kwargs
) -> Dict[str, Any]:
"""
Build source context from RAG retrieval results.
Args:
docs: List of retrieved documents
docs_together: Concatenated document content (for backward compatibility)
Returns:
Dictionary with source variables
"""
context = {}
if docs:
context["documents"] = docs
context["count"] = len(docs)
if docs_together:
context["docs_together"] = docs_together # Add docs_together for custom templates
context["content"] = docs_together
context["summaries"] = docs_together
return context
class ToolsNamespace(NamespaceBuilder):
"""Pre-executed tools namespace: {{ tools.* }}"""
@property
def namespace_name(self) -> str:
return "tools"
def build(
self, tools_data: Optional[Dict[str, Any]] = None, **kwargs
) -> Dict[str, Any]:
"""
Build tools context with pre-executed tool results.
Args:
tools_data: Dictionary of pre-fetched tool results organized by tool name
e.g., {"memory": {"notes": "content", "tasks": "list"}}
Returns:
Dictionary with tool results organized by tool name
"""
if not tools_data:
return {}
safe_data = {}
for tool_name, tool_result in tools_data.items():
if isinstance(tool_result, (str, dict, list, int, float, bool, type(None))):
safe_data[tool_name] = tool_result
else:
logger.warning(
f"Skipping non-serializable tool result for '{tool_name}': {type(tool_result)}"
)
return safe_data
class NamespaceManager:
"""Manages all namespace builders and context assembly"""
def __init__(self):
self._builders = {
"system": SystemNamespace(),
"passthrough": PassthroughNamespace(),
"source": SourceNamespace(),
"tools": ToolsNamespace(),
}
def build_context(self, **kwargs) -> Dict[str, Any]:
"""
Build complete template context from all namespaces.
Args:
**kwargs: Parameters to pass to namespace builders
Returns:
Complete context dictionary for template rendering
"""
context = {}
for namespace_name, builder in self._builders.items():
try:
namespace_context = builder.build(**kwargs)
# Always include namespace, even if empty, to prevent undefined errors
context[namespace_name] = namespace_context if namespace_context else {}
except Exception as e:
logger.error(f"Failed to build {namespace_name} namespace: {str(e)}")
# Include empty namespace on error to prevent template failures
context[namespace_name] = {}
return context
def get_builder(self, namespace_name: str) -> Optional[NamespaceBuilder]:
"""Get specific namespace builder"""
return self._builders.get(namespace_name)

View File

@@ -0,0 +1,161 @@
import logging
from typing import Any, Dict, List, Optional, Set
from jinja2 import (
ChainableUndefined,
Environment,
nodes,
select_autoescape,
TemplateSyntaxError,
)
from jinja2.exceptions import UndefinedError
logger = logging.getLogger(__name__)
class TemplateRenderError(Exception):
"""Raised when template rendering fails"""
pass
class TemplateEngine:
"""Jinja2-based template engine for dynamic prompt rendering"""
def __init__(self):
self._env = Environment(
undefined=ChainableUndefined,
trim_blocks=True,
lstrip_blocks=True,
autoescape=select_autoescape(default_for_string=True, default=True),
)
def render(self, template_content: str, context: Dict[str, Any]) -> str:
"""
Render template with provided context.
Args:
template_content: Raw template string with Jinja2 syntax
context: Dictionary of variables to inject into template
Returns:
Rendered template string
Raises:
TemplateRenderError: If template syntax is invalid or variables undefined
"""
if not template_content:
return ""
try:
template = self._env.from_string(template_content)
return template.render(**context)
except TemplateSyntaxError as e:
error_msg = f"Template syntax error at line {e.lineno}: {e.message}"
logger.error(error_msg)
raise TemplateRenderError(error_msg) from e
except UndefinedError as e:
error_msg = f"Undefined variable in template: {e.message}"
logger.error(error_msg)
raise TemplateRenderError(error_msg) from e
except Exception as e:
error_msg = f"Template rendering failed: {str(e)}"
logger.error(error_msg)
raise TemplateRenderError(error_msg) from e
def validate_template(self, template_content: str) -> bool:
"""
Validate template syntax without rendering.
Args:
template_content: Template string to validate
Returns:
True if template is syntactically valid
"""
if not template_content:
return True
try:
self._env.from_string(template_content)
return True
except TemplateSyntaxError as e:
logger.debug(f"Template syntax invalid at line {e.lineno}: {e.message}")
return False
except Exception as e:
logger.debug(f"Template validation error: {type(e).__name__}: {str(e)}")
return False
def extract_variables(self, template_content: str) -> Set[str]:
"""
Extract all variable names from template.
Args:
template_content: Template string to analyze
Returns:
Set of variable names found in template
"""
if not template_content:
return set()
try:
ast = self._env.parse(template_content)
return set(self._env.get_template_module(ast).make_module().keys())
except TemplateSyntaxError as e:
logger.debug(f"Cannot extract variables - syntax error at line {e.lineno}")
return set()
except Exception as e:
logger.debug(f"Cannot extract variables: {type(e).__name__}")
return set()
def extract_tool_usages(
self, template_content: str
) -> Dict[str, Set[Optional[str]]]:
"""Extract tool and action references from a template"""
if not template_content:
return {}
try:
ast = self._env.parse(template_content)
except TemplateSyntaxError as e:
logger.debug(f"extract_tool_usages - syntax error at line {e.lineno}")
return {}
except Exception as e:
logger.debug(f"extract_tool_usages - parse error: {type(e).__name__}")
return {}
usages: Dict[str, Set[Optional[str]]] = {}
def record(path: List[str]) -> None:
if not path:
return
tool_name = path[0]
action_name = path[1] if len(path) > 1 else None
if not tool_name:
return
tool_entry = usages.setdefault(tool_name, set())
tool_entry.add(action_name)
for node in ast.find_all(nodes.Getattr):
path = []
current = node
while isinstance(current, nodes.Getattr):
path.append(current.attr)
current = current.node
if isinstance(current, nodes.Name) and current.name == "tools":
path.reverse()
record(path)
for node in ast.find_all(nodes.Getitem):
path = []
current = node
while isinstance(current, nodes.Getitem):
key = current.arg
if isinstance(key, nodes.Const) and isinstance(key.value, str):
path.append(key.value)
else:
path = []
break
current = current.node
if path and isinstance(current, nodes.Name) and current.name == "tools":
path.reverse()
record(path)
return usages

View File

@@ -15,10 +15,11 @@ class ElevenlabsTTS(BaseTTS):
def text_to_speech(self, text):
lang = "en"
audio = self.client.generate(
audio = self.client.text_to_speech.convert(
voice_id="nPczCjzI2devNBz1zQrb",
model_id="eleven_multilingual_v2",
text=text,
model="eleven_multilingual_v2",
voice="Brian",
output_format="mp3_44100_128"
)
audio_data = BytesIO()
for chunk in audio:

View File

@@ -0,0 +1,18 @@
from application.tts.google_tts import GoogleTTS
from application.tts.elevenlabs import ElevenlabsTTS
from application.tts.base import BaseTTS
class TTSCreator:
tts_providers = {
"google_tts": GoogleTTS,
"elevenlabs": ElevenlabsTTS,
}
@classmethod
def create_tts(cls, tts_type, *args, **kwargs)-> BaseTTS:
tts_class = cls.tts_providers.get(tts_type.lower())
if not tts_class:
raise ValueError(f"No tts class found for type {tts_type}")
return tts_class(*args, **kwargs)

View File

@@ -7,6 +7,8 @@ import tiktoken
from flask import jsonify, make_response
from werkzeug.utils import secure_filename
from application.core.model_utils import get_token_limit
from application.core.settings import settings
@@ -21,7 +23,7 @@ def get_encoding():
def get_gpt_model() -> str:
"""Get the appropriate GPT model based on provider"""
"""Get GPT model based on provider"""
model_map = {
"openai": "gpt-4o-mini",
"anthropic": "claude-2",
@@ -32,16 +34,7 @@ def get_gpt_model() -> str:
def safe_filename(filename):
"""
Creates a safe filename that preserves the original extension.
Uses secure_filename, but ensures a proper filename is returned even with non-Latin characters.
Args:
filename (str): The original filename
Returns:
str: A safe filename that can be used for storage
"""
"""Create safe filename, preserving extension. Handles non-Latin characters."""
if not filename:
return str(uuid.uuid4())
_, extension = os.path.splitext(filename)
@@ -83,8 +76,23 @@ def count_tokens_docs(docs):
return tokens
def calculate_doc_token_budget(
model_id: str = "gpt-4o", history_token_limit: int = 2000
) -> int:
total_context = get_token_limit(model_id)
reserved = sum(settings.RESERVED_TOKENS.values())
doc_budget = total_context - history_token_limit - reserved
return max(doc_budget, 1000)
def get_missing_fields(data, required_fields):
"""Check for missing required fields. Returns list of missing field names."""
return [field for field in required_fields if field not in data]
def check_required_fields(data, required_fields):
missing_fields = [field for field in required_fields if field not in data]
"""Validate required fields. Returns Flask 400 response if validation fails, None otherwise."""
missing_fields = get_missing_fields(data, required_fields)
if missing_fields:
return make_response(
jsonify(
@@ -98,7 +106,8 @@ def check_required_fields(data, required_fields):
return None
def validate_required_fields(data, required_fields):
def get_field_validation_errors(data, required_fields):
"""Check for missing and empty fields. Returns dict with 'missing_fields' and 'empty_fields', or None."""
missing_fields = []
empty_fields = []
@@ -107,12 +116,24 @@ def validate_required_fields(data, required_fields):
missing_fields.append(field)
elif not data[field]:
empty_fields.append(field)
errors = []
if missing_fields:
errors.append(f"Missing required fields: {', '.join(missing_fields)}")
if empty_fields:
errors.append(f"Empty values in required fields: {', '.join(empty_fields)}")
if errors:
if missing_fields or empty_fields:
return {"missing_fields": missing_fields, "empty_fields": empty_fields}
return None
def validate_required_fields(data, required_fields):
"""Validate required fields (must exist and be non-empty). Returns Flask 400 response if validation fails, None otherwise."""
errors_dict = get_field_validation_errors(data, required_fields)
if errors_dict:
errors = []
if errors_dict["missing_fields"]:
errors.append(
f"Missing required fields: {', '.join(errors_dict['missing_fields'])}"
)
if errors_dict["empty_fields"]:
errors.append(
f"Empty values in required fields: {', '.join(errors_dict['empty_fields'])}"
)
return make_response(
jsonify({"success": False, "message": " | ".join(errors)}), 400
)
@@ -123,19 +144,13 @@ def get_hash(data):
return hashlib.md5(data.encode(), usedforsecurity=False).hexdigest()
def limit_chat_history(history, max_token_limit=None, gpt_model="docsgpt"):
"""
Limits chat history based on token count.
Returns a list of messages that fit within the token limit.
"""
from application.core.settings import settings
def limit_chat_history(history, max_token_limit=None, model_id="docsgpt-local"):
"""Limit chat history to fit within token limit."""
model_token_limit = get_token_limit(model_id)
max_token_limit = (
max_token_limit
if max_token_limit
and max_token_limit
< settings.LLM_TOKEN_LIMITS.get(gpt_model, settings.DEFAULT_MAX_HISTORY)
else settings.LLM_TOKEN_LIMITS.get(gpt_model, settings.DEFAULT_MAX_HISTORY)
if max_token_limit and max_token_limit < model_token_limit
else model_token_limit
)
if not history:
@@ -161,13 +176,17 @@ def limit_chat_history(history, max_token_limit=None, gpt_model="docsgpt"):
def validate_function_name(function_name):
"""Validates if a function name matches the allowed pattern."""
"""Validate function name matches allowed pattern (alphanumeric, underscore, hyphen)."""
if not re.match(r"^[a-zA-Z0-9_-]+$", function_name):
return False
return True
def generate_image_url(image_path):
if isinstance(image_path, str) and (
image_path.startswith("http://") or image_path.startswith("https://")
):
return image_path
strategy = getattr(settings, "URL_STRATEGY", "backend")
if strategy == "s3":
bucket_name = getattr(settings, "S3_BUCKET_NAME", "docsgpt-test-bucket")
@@ -176,3 +195,51 @@ def generate_image_url(image_path):
else:
base_url = getattr(settings, "API_URL", "http://localhost:7091")
return f"{base_url}/api/images/{image_path}"
def clean_text_for_tts(text: str) -> str:
"""
clean text for Text-to-Speech processing.
"""
# Handle code blocks and links
text = re.sub(r"```mermaid[\s\S]*?```", " flowchart, ", text) ## ```mermaid...```
text = re.sub(r"```[\s\S]*?```", " code block, ", text) ## ```code```
text = re.sub(r"\[([^\]]+)\]\([^\)]+\)", r"\1", text) ## [text](url)
text = re.sub(r"!\[([^\]]*)\]\([^\)]+\)", "", text) ## ![alt](url)
# Remove markdown formatting
text = re.sub(r"`([^`]+)`", r"\1", text) ## `code`
text = re.sub(r"\{([^}]*)\}", r" \1 ", text) ## {text}
text = re.sub(r"[{}]", " ", text) ## unmatched {}
text = re.sub(r"\[([^\]]+)\]", r" \1 ", text) ## [text]
text = re.sub(r"[\[\]]", " ", text) ## unmatched []
text = re.sub(r"(\*\*|__)(.*?)\1", r"\2", text) ## **bold** __bold__
text = re.sub(r"(\*|_)(.*?)\1", r"\2", text) ## *italic* _italic_
text = re.sub(r"^#{1,6}\s+", "", text, flags=re.MULTILINE) ## # headers
text = re.sub(r"^>\s+", "", text, flags=re.MULTILINE) ## > blockquotes
text = re.sub(r"^[\s]*[-\*\+]\s+", "", text, flags=re.MULTILINE) ## - * + lists
text = re.sub(r"^[\s]*\d+\.\s+", "", text, flags=re.MULTILINE) ## 1. numbered lists
text = re.sub(
r"^[\*\-_]{3,}\s*$", "", text, flags=re.MULTILINE
) ## --- *** ___ rules
text = re.sub(r"<[^>]*>", "", text) ## <html> tags
# Remove non-ASCII (emojis, special Unicode)
text = re.sub(r"[^\x20-\x7E\n\r\t]", "", text)
# Replace special sequences
text = re.sub(r"-->", ", ", text) ## -->
text = re.sub(r"<--", ", ", text) ## <--
text = re.sub(r"=>", ", ", text) ## =>
text = re.sub(r"::", " ", text) ## ::
# Normalize whitespace
text = re.sub(r"\s+", " ", text)
text = text.strip()
return text

View File

@@ -39,6 +39,7 @@ sources_collection = db["sources"]
# Constants
MIN_TOKENS = 150
MAX_TOKENS = 1250
RECURSION_DEPTH = 2
@@ -164,7 +165,7 @@ def run_agent_logic(agent_config, input_data):
agent_type,
endpoint="webhook",
llm_name=settings.LLM_PROVIDER,
gpt_model=settings.LLM_NAME,
model_id=settings.LLM_NAME,
api_key=settings.API_KEY,
user_api_key=user_api_key,
prompt=prompt,
@@ -179,7 +180,7 @@ def run_agent_logic(agent_config, input_data):
prompt=prompt,
chunks=chunks,
token_limit=settings.DEFAULT_MAX_HISTORY,
gpt_model=settings.LLM_NAME,
model_id=settings.LLM_NAME,
user_api_key=user_api_key,
decoded_token=decoded_token,
)
@@ -740,7 +741,13 @@ def remote_worker(
if os.path.exists(full_path):
shutil.rmtree(full_path)
logging.info("remote_worker task completed successfully")
return {"urls": source_data, "name_job": name_job, "user": user, "limited": False}
return {
"id": str(id),
"urls": source_data,
"name_job": name_job,
"user": user,
"limited": False,
}
def sync(

View File

@@ -72,4 +72,4 @@ services:
- mongodb_data_container:/data/db
volumes:
mongodb_data_container:
mongodb_data_container:

5997
docs/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,9 +8,9 @@
"dependencies": {
"@vercel/analytics": "^1.1.1",
"docsgpt-react": "^0.5.1",
"next": "^15.3.3",
"nextra": "^2.13.2",
"nextra-theme-docs": "^2.13.2",
"next": "^15.5.6",
"nextra": "^4.6.0",
"nextra-theme-docs": "^4.6.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
}

View File

@@ -107,3 +107,13 @@ Once an agent is created, you can:
* Modify any of its configuration settings (name, description, source, prompt, tools, type).
* **Generate a Public Link:** From the edit screen, you can create a shareable public link that allows others to import and use your agent.
* **Get a Webhook URL:** You can also obtain a Webhook URL for the agent. This allows external applications or services to trigger the agent and receive responses programmatically, enabling powerful integrations and automations.
## Seeding Premade Agents from YAML
You can bootstrap a fresh DocsGPT deployment with a curated set of agents by seeding them directly into MongoDB.
1. **Customize the configuration** edit `application/seed/config/premade_agents.yaml` (or copy from `application/seed/config/agents_template.yaml`) to describe the agents you want to provision. Each entry lets you define prompts, tools, and optional data sources.
2. **Ensure dependencies are running** MongoDB must be reachable using the credentials in `.env`, and a Celery worker should be available if any agent sources need to be ingested via `ingest_remote`.
3. **Execute the seeder** run `python -m application.seed.commands init`. Add `--force` when you need to reseed an existing environment.
The seeder keeps templates under the `system` user so they appear in the UI for anyone to clone or customize. Environment variable placeholders such as `${MY_TOKEN}` inside tool configs are resolved during the seeding process.

View File

@@ -42,7 +42,7 @@ To run the DocsGPT backend locally, you'll need to set up a Python environment a
* **Option 1: Using a `.env` file (Recommended):**
* If you haven't already, create a file named `.env` in the **root directory** of your DocsGPT project.
* Modify the `.env` file to adjust settings as needed. You can find a comprehensive list of configurable options in [`application/core/settings.py`](application/core/settings.py).
* Modify the `.env` file to adjust settings as needed. You can find a comprehensive list of configurable options in [`application/core/settings.py`](https://github.com/arc53/DocsGPT/blob/main/application/core/settings.py).
* **Option 2: Exporting Environment Variables:**
* Alternatively, you can export environment variables directly in your terminal. However, using a `.env` file is generally more organized for development.
@@ -67,7 +67,7 @@ To run the DocsGPT backend locally, you'll need to set up a Python environment a
3. **Download Embedding Model:**
The backend requires an embedding model. Download the `mpnet-base-v2` model and place it in the `model/` directory within the project root. You can use the following script:
The backend requires an embedding model. Download the `mpnet-base-v2` model and place it in the `models/` directory within the project root. You can use the following script:
```bash
wget https://d3dg1063dc54p9.cloudfront.net/models/embeddings/mpnet-base-v2.zip
@@ -75,7 +75,7 @@ To run the DocsGPT backend locally, you'll need to set up a Python environment a
rm mpnet-base-v2.zip
```
Alternatively, you can manually download the zip file from [here](https://d3dg1063dc54p9.cloudfront.net/models/embeddings/mpnet-base-v2.zip), unzip it, and place the extracted folder in `model/`.
Alternatively, you can manually download the zip file from [here](https://d3dg1063dc54p9.cloudfront.net/models/embeddings/mpnet-base-v2.zip), unzip it, and place the extracted folder in `models/`.
4. **Install Backend Dependencies:**
@@ -160,4 +160,4 @@ To run the DocsGPT frontend locally, you'll need Node.js and npm (Node Package M
This command will start the Vite development server. The frontend application will typically be accessible at [http://localhost:5173/](http://localhost:5173/). The terminal will display the exact URL where the frontend is running.
With both the backend and frontend running, you should now have a fully functional DocsGPT development environment. You can access the application in your browser at [http://localhost:5173/](http://localhost:5173/) and start developing!
With both the backend and frontend running, you should now have a fully functional DocsGPT development environment. You can access the application in your browser at [http://localhost:5173/](http://localhost:5173/) and start developing!

View File

@@ -1,49 +1,453 @@
---
title: Customizing Prompts
description: This guide will explain how to change prompts in DocsGPT and why it might be benefitial. Additionaly this article expains additional variables that can be used in prompts.
title: Customizing Prompts
description: This guide explains how to customize prompts in DocsGPT using the new template-based system with dynamic variable injection.
---
import Image from 'next/image'
# Customizing the Main Prompt
# Customizing Prompts in DocsGPT
Customizing the main prompt for DocsGPT gives you the ability to tailor the AI's responses to your specific requirements. By modifying the prompt text, you can achieve more accurate and relevant answers. Here's how you can do it:
Customizing prompts for DocsGPT gives you powerful control over the AI's behavior and responses. With the new template-based system, you can inject dynamic context through organized namespaces, making prompts flexible and maintainable without hardcoding values.
## Quick Start
1. Navigate to `SideBar -> Settings`.
2.In Settings select the `Active Prompt` now you will be able to see various prompts style.x
3.Click on the `edit icon` on the prompt of your choice and you will be able to see the current prompt for it,you can now customise the prompt as per your choice.
2. In Settings, select the `Active Prompt` to see various prompt styles.
3. Click on the `edit icon` on your chosen prompt to customize it.
### Video Demo
<Image src="/prompts.gif" alt="prompts" width={800} height={500} />
---
## Template-Based Prompt System
## Example Prompt Modification
DocsGPT now uses **Jinja2 templating** with four organized namespaces for dynamic variable injection:
### Available Namespaces
#### 1. **`system`** - System Metadata
Access system-level information:
```jinja
{{ system.date }} # Current date (YYYY-MM-DD)
{{ system.time }} # Current time (HH:MM:SS)
{{ system.timestamp }} # ISO 8601 timestamp
{{ system.request_id }} # Unique request identifier
{{ system.user_id }} # Current user ID
```
#### 2. **`source`** - Retrieved Documents
Access RAG (Retrieval-Augmented Generation) document context:
```jinja
{{ source.content }} # Concatenated document content
{{ source.summaries }} # Alias for content (backward compatible)
{{ source.documents }} # List of document objects
{{ source.count }} # Number of retrieved documents
```
#### 3. **`passthrough`** - Request Parameters
Access custom parameters passed in the API request:
```jinja
{{ passthrough.company }} # Custom field from request
{{ passthrough.user_name }} # User-provided data
{{ passthrough.context }} # Any custom parameter
```
To use passthrough data, send it in your API request:
```json
{
"question": "What is the pricing?",
"passthrough": {
"company": "Acme Corp",
"user_name": "Alice",
"plan_type": "enterprise"
}
}
```
#### 4. **`tools`** - Pre-fetched Tool Data
Access results from tools that run before the agent (like memory tool):
```jinja
{{ tools.memory.root }} # Memory tool directory listing
{{ tools.memory.available }} # Boolean: is memory available
```
---
## Example Prompts
### Basic Prompt with Documents
```jinja
You are a helpful AI assistant for DocsGPT.
Current date: {{ system.date }}
Use the following documents to answer the question:
{{ source.content }}
Provide accurate, helpful answers with code examples when relevant.
```
### Advanced Prompt with All Namespaces
```jinja
You are an AI assistant for {{ passthrough.company }}.
**System Info:**
- Date: {{ system.date }}
- Request ID: {{ system.request_id }}
**User Context:**
- User: {{ passthrough.user_name }}
- Role: {{ passthrough.role }}
**Available Documents ({{ source.count }}):**
{{ source.content }}
**Memory Context:**
{% if tools.memory.available %}
{{ tools.memory.root }}
{% else %}
No saved context available.
{% endif %}
Please provide detailed, accurate answers based on the documents above.
```
### Conditional Logic Example
```jinja
You are a DocsGPT assistant.
{% if source.count > 0 %}
I found {{ source.count }} relevant document(s):
{{ source.content }}
Base your answer on these documents.
{% else %}
No documents were found. Please answer based on your general knowledge.
{% endif %}
```
---
## Migration Guide
### Legacy Format (Still Supported)
The old `{summaries}` format continues to work for backward compatibility:
**Original Prompt:**
```markdown
You are a DocsGPT, friendly and helpful AI assistant by Arc53 that provides help with documents. You give thorough answers with code examples if possible.
Use the following pieces of context to help answer the users question. If it's not relevant to the question, provide friendly responses.
You have access to chat history, and can use it to help answer the question.
When using code examples, use the following format:
You are a helpful assistant.
(code)
Documents:
{summaries}
```
Note that `{summaries}` allows model to see and respond to your upploaded documents. If you don't want this functionality you can safely remove it from the customized prompt.
This will automatically substitute `{summaries}` with document content.
Feel free to customize the prompt to align it with your specific use case or the kind of responses you want from the AI. For example, you can focus on specific document types, industries, or topics to get more targeted results.
### New Template Format (Recommended)
Migrate to the new template syntax for more flexibility:
```jinja
You are a helpful assistant.
Documents:
{{ source.content }}
```
**Migration mapping:**
- `{summaries}` → `{{ source.content }}` or `{{ source.summaries }}`
---
## Best Practices
### 1. **Use Descriptive Context**
```jinja
**Retrieved Documents:**
{{ source.content }}
**User Query Context:**
- Company: {{ passthrough.company }}
- Department: {{ passthrough.department }}
```
### 2. **Handle Missing Data Gracefully**
```jinja
{% if passthrough.user_name %}
Hello {{ passthrough.user_name }}!
{% endif %}
```
### 3. **Leverage Memory for Continuity**
```jinja
{% if tools.memory.available %}
**Previous Context:**
{{ tools.memory.root }}
{% endif %}
**Current Question:**
Please consider the above context when answering.
```
### 4. **Add Clear Instructions**
```jinja
You are a technical support assistant.
**Guidelines:**
1. Always reference the documents below
2. Provide step-by-step instructions
3. Include code examples when relevant
**Reference Documents:**
{{ source.content }}
```
---
## Advanced Features
### Looping Over Documents
```jinja
{% for doc in source.documents %}
**Source {{ loop.index }}:** {{ doc.filename }}
{{ doc.text }}
{% endfor %}
```
### Date-Based Behavior
```jinja
{% if system.date > "2025-01-01" %}
Note: This is information from 2025 or later.
{% endif %}
```
### Custom Formatting
```jinja
**Request Information**
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
• Request ID: {{ system.request_id }}
• User: {{ passthrough.user_name | default("Guest") }}
• Time: {{ system.time }}
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
```
---
## Tool Pre-Fetching
### Memory Tool Configuration
Enable memory tool pre-fetching to inject saved context into prompts:
```python
# In your tool configuration
{
"name": "memory",
"config": {
"pre_fetch_enabled": true # Default: true
}
}
```
Control pre-fetching globally:
```bash
# .env file
ENABLE_TOOL_PREFETCH=true
```
Or per-request:
```json
{
"question": "What are the requirements?",
"disable_tool_prefetch": false
}
```
---
## Debugging Prompts
### View Rendered Prompts in Logs
Set log level to `INFO` to see the final rendered prompt sent to the LLM:
```bash
export LOG_LEVEL=INFO
```
You'll see output like:
```
INFO - Rendered system prompt for agent (length: 1234 chars):
================================================================================
You are a helpful assistant for Acme Corp.
Current date: 2025-10-30
Request ID: req_abc123
Documents:
Technical documentation about...
================================================================================
```
### Template Validation
Test your template syntax before saving:
```python
from application.api.answer.services.prompt_renderer import PromptRenderer
renderer = PromptRenderer()
is_valid = renderer.validate_template("Your prompt with {{ variables }}")
```
---
## Common Use Cases
### 1. Customer Support Bot
```jinja
You are a customer support assistant for {{ passthrough.company }}.
**Customer:** {{ passthrough.customer_name }}
**Ticket ID:** {{ system.request_id }}
**Date:** {{ system.date }}
**Knowledge Base:**
{{ source.content }}
**Previous Interactions:**
{{ tools.memory.root }}
Please provide helpful, friendly support based on the knowledge base above.
```
### 2. Technical Documentation Assistant
```jinja
You are a technical documentation expert.
**Available Documentation ({{ source.count }} documents):**
{{ source.content }}
**Requirements:**
- Provide code examples in {{ passthrough.language }}
- Focus on {{ passthrough.framework }} best practices
- Include relevant links when possible
```
### 3. Internal Knowledge Base
```jinja
You are an internal AI assistant for {{ passthrough.department }}.
**Employee:** {{ passthrough.employee_name }}
**Access Level:** {{ passthrough.access_level }}
**Relevant Documents:**
{{ source.content }}
Provide detailed answers appropriate for {{ passthrough.access_level }} access level.
```
---
## Template Syntax Reference
### Variables
```jinja
{{ variable_name }} # Output variable
{{ namespace.field }} # Access nested field
{{ variable | default("N/A") }} # Default value
```
### Conditionals
```jinja
{% if condition %}
Content
{% elif other_condition %}
Other content
{% else %}
Default content
{% endif %}
```
### Loops
```jinja
{% for item in list %}
{{ item.field }}
{% endfor %}
```
### Comments
```jinja
{# This is a comment and won't appear in output #}
```
---
## Security Considerations
1. **Input Sanitization**: Passthrough data is automatically sanitized to prevent injection attacks
2. **Type Filtering**: Only primitive types (string, int, float, bool, None) are allowed in passthrough
3. **Autoescaping**: Jinja2 autoescaping is enabled by default
4. **Size Limits**: Consider the token budget when including large documents
---
## Troubleshooting
### Problem: Variables Not Rendering
**Solution:** Ensure you're using the correct namespace:
```jinja
❌ {{ company }}
✅ {{ passthrough.company }}
```
### Problem: Empty Output for Tool Data
**Solution:** Check that tool pre-fetching is enabled and the tool is configured correctly.
### Problem: Syntax Errors
**Solution:** Validate template syntax. Common issues:
```jinja
❌ {{ variable } # Missing closing brace
❌ {% if x % # Missing closing %}
✅ {{ variable }}
✅ {% if x %}...{% endif %}
```
### Problem: Legacy Prompts Not Working
**Solution:** The system auto-detects template syntax. If your prompt uses `{summaries}`, it will work in legacy mode. To use new features, add `{{ }}` syntax.
---
## API Reference
### Render Prompt via API
```python
from application.api.answer.services.prompt_renderer import PromptRenderer
renderer = PromptRenderer()
rendered = renderer.render_prompt(
prompt_content="Your template with {{ passthrough.name }}",
user_id="user_123",
request_id="req_456",
passthrough_data={"name": "Alice"},
docs_together="Document content here",
tools_data={"memory": {"root": "Files: notes.txt"}}
)
```
---
## Conclusion
Customizing the main prompt for DocsGPT allows you to tailor the AI's responses to your unique requirements. Whether you need in-depth explanations, code examples, or specific insights, you can achieve it by modifying the main prompt. Remember to experiment and fine-tune your prompts to get the best results.
The new template-based prompt system provides powerful flexibility while maintaining backward compatibility. By leveraging namespaces, you can create dynamic, context-aware prompts that adapt to your specific use case.
**Key Benefits:**
- ✅ Dynamic variable injection
- ✅ Organized namespaces
- ✅ Backward compatible
- ✅ Security built-in
- ✅ Easy to debug
Start with simple templates and gradually add complexity as needed. Happy prompting! 🚀

View File

@@ -43,7 +43,8 @@ The easiest way to launch DocsGPT is using the provided `setup.sh` script. This
2) Serve Local (with Ollama)
3) Connect Local Inference Engine
4) Connect Cloud API Provider
Choose option (1-4):
5) Advanced: Build images locally (for developers)
Choose option (1-5):
```
Let's break down each option:
@@ -56,6 +57,8 @@ The easiest way to launch DocsGPT is using the provided `setup.sh` script. This
* **4) Connect Cloud API Provider:** This option lets you connect DocsGPT to a commercial Cloud API provider such as OpenAI, Google (Vertex AI/Gemini), Anthropic (Claude), Groq, HuggingFace Inference API, or Azure OpenAI. You will need an API key from your chosen provider. Select this if you prefer to use a powerful cloud-based LLM.
* **5) Modify DocsGPT's source code and rebuild the Docker images locally.** Instead of pulling prebuilt images from Docker Hub or using the hosted/public API, you build the entire backend and frontend from source, customizing how DocsGPT works internally, or run it in an environment without internet access.
After selecting an option and providing any required information (like API keys or model names), the script will configure your `.env` file and start DocsGPT using Docker Compose.
4. **Access DocsGPT in your browser:**
@@ -116,4 +119,4 @@ If you prefer a more manual approach, you can follow our [Docker Deployment docu
For more advanced customization of DocsGPT settings, such as configuring vector stores, embedding models, and other parameters, please refer to the [DocsGPT Settings documentation](/Deploying/DocsGPT-Settings). This guide explains how to modify the `.env` file or `settings.py` for deeper configuration.
Enjoy using DocsGPT!
Enjoy using DocsGPT!

View File

@@ -1,4 +1,6 @@
# Please put appropriate value
VITE_BASE_URL=http://localhost:5173
VITE_API_HOST=http://127.0.0.1:7091
VITE_API_STREAMING=true
VITE_API_STREAMING=true
VITE_NOTIFICATION_TEXT="What's new in 0.14.0 — Changelog"
VITE_NOTIFICATION_LINK="https://blog.docsgpt.cloud/docsgpt-0-14-agents-automate-integrate-and-innovate/"

View File

@@ -1,17 +0,0 @@
node_modules/
dist/
prettier.config.cjs
.eslintrc.cjs
env.d.ts
public/
assets/
vite-env.d.ts
.prettierignore
package-lock.json
package.json
postcss.config.cjs
prettier.config.cjs
tailwind.config.cjs
tsconfig.json
tsconfig.node.json
vite.config.ts

View File

@@ -1,45 +0,0 @@
module.exports = {
env: {
browser: true,
es2021: true,
node: true,
},
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react/recommended',
'plugin:prettier/recommended',
],
overrides: [],
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
plugins: ['react', 'unused-imports'],
rules: {
'react/prop-types': 'off',
'unused-imports/no-unused-imports': 'error',
'react/react-in-jsx-scope': 'off',
'prettier/prettier': [
'error',
{
endOfLine: 'auto',
},
],
},
settings: {
'import/parsers': {
'@typescript-eslint/parser': ['.ts', '.tsx'],
},
react: {
version: 'detect',
},
'import/resolver': {
node: {
paths: ['src'],
extensions: ['.js', '.jsx', '.ts', '.tsx'],
},
},
},
};

78
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,78 @@
import js from '@eslint/js'
import tsParser from '@typescript-eslint/parser'
import tsPlugin from '@typescript-eslint/eslint-plugin'
import react from 'eslint-plugin-react'
import unusedImports from 'eslint-plugin-unused-imports'
import prettier from 'eslint-plugin-prettier'
import globals from 'globals'
export default [
{
ignores: [
'node_modules/',
'dist/',
'prettier.config.cjs',
'.eslintrc.cjs',
'env.d.ts',
'public/',
'assets/',
'vite-env.d.ts',
'.prettierignore',
'package-lock.json',
'package.json',
'postcss.config.cjs',
'tailwind.config.cjs',
'tsconfig.json',
'tsconfig.node.json',
'vite.config.ts',
],
},
{
files: ['**/*.{js,jsx,ts,tsx}'],
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
parser: tsParser,
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
globals: {
...globals.browser,
...globals.es2021,
...globals.node,
},
},
plugins: {
'@typescript-eslint': tsPlugin,
react,
'unused-imports': unusedImports,
prettier,
},
rules: {
...js.configs.recommended.rules,
...tsPlugin.configs.recommended.rules,
...react.configs.recommended.rules,
...prettier.configs.recommended.rules,
'react/prop-types': 'off',
'unused-imports/no-unused-imports': 'error',
'react/react-in-jsx-scope': 'off',
'no-undef': 'off',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': 'warn',
'@typescript-eslint/no-unused-expressions': 'warn',
'prettier/prettier': [
'error',
{
endOfLine: 'auto',
},
],
},
settings: {
react: {
version: 'detect',
},
},
},
]

File diff suppressed because it is too large Load Diff

View File

@@ -19,21 +19,21 @@
]
},
"dependencies": {
"@reduxjs/toolkit": "^2.8.2",
"@reduxjs/toolkit": "^2.10.1",
"chart.js": "^4.4.4",
"clsx": "^2.1.1",
"copy-to-clipboard": "^3.3.3",
"i18next": "^25.5.3",
"i18next-browser-languagedetector": "^8.0.2",
"i18next-browser-languagedetector": "^8.2.0",
"lodash": "^4.17.21",
"mermaid": "^11.6.0",
"mermaid": "^11.12.1",
"prop-types": "^15.8.1",
"react": "^19.1.0",
"react-chartjs-2": "^5.3.0",
"react-dom": "^19.0.0",
"react-dom": "^19.1.1",
"react-dropzone": "^14.3.8",
"react-google-drive-picker": "^1.2.2",
"react-i18next": "^15.4.0",
"react-i18next": "^16.2.4",
"react-markdown": "^9.0.1",
"react-redux": "^9.2.0",
"react-router-dom": "^7.6.1",
@@ -46,30 +46,28 @@
"devDependencies": {
"@tailwindcss/postcss": "^4.1.10",
"@types/lodash": "^4.17.20",
"@types/mermaid": "^9.1.0",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.0.0",
"@types/react-dom": "^19.1.7",
"@types/react-syntax-highlighter": "^15.5.13",
"@typescript-eslint/eslint-plugin": "^5.51.0",
"@typescript-eslint/parser": "^5.62.0",
"@typescript-eslint/eslint-plugin": "^8.46.3",
"@typescript-eslint/parser": "^8.46.3",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^8.57.1",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.5",
"eslint-config-standard-with-typescript": "^34.0.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-n": "^15.7.0",
"eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-n": "^17.23.1",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-promise": "^6.6.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-unused-imports": "^4.1.4",
"husky": "^8.0.0",
"husky": "^9.1.7",
"lint-staged": "^15.3.0",
"postcss": "^8.4.49",
"prettier": "^3.5.3",
"prettier-plugin-tailwindcss": "^0.6.13",
"prettier-plugin-tailwindcss": "^0.7.1",
"tailwindcss": "^4.1.11",
"typescript": "^5.8.3",
"vite": "^6.3.5",
"vite": "^7.2.0",
"vite-plugin-svgr": "^4.3.0"
}
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e3e3e3"><path d="M240-80q-33 0-56.5-23.5T160-160v-640q0-33 23.5-56.5T240-880h480q33 0 56.5 23.5T800-800v640q0 33-23.5 56.5T720-80H240Zm0-80h480v-640H240v640Zm88-104 56-56-56-56-56 56 56 56Zm0-160 56-56-56-56-56 56 56 56Zm0-160 56-56-56-56-56 56 56 56Zm120 280h232v-80H448v80Zm0-160h232v-80H448v80Zm0-160h232v-80H448v80ZM240-160v-640 640Z"/></svg>

After

Width:  |  Height:  |  Size: 446 B

View File

@@ -15,6 +15,7 @@ import useTokenAuth from './hooks/useTokenAuth';
import Navigation from './Navigation';
import PageNotFound from './PageNotFound';
import Setting from './settings';
import Notification from './components/Notification';
function AuthWrapper({ children }: { children: React.ReactNode }) {
const { isAuthLoading } = useTokenAuth();
@@ -52,11 +53,27 @@ function MainLayout() {
}
export default function App() {
const [, , componentMounted] = useDarkTheme();
const [showNotification, setShowNotification] = useState<boolean>(() => {
const saved = localStorage.getItem('showNotification');
return saved ? JSON.parse(saved) : true;
});
const notificationText = import.meta.env.VITE_NOTIFICATION_TEXT;
const notificationLink = import.meta.env.VITE_NOTIFICATION_LINK;
if (!componentMounted) {
return <div />;
}
return (
<div className="relative h-full overflow-hidden">
{notificationLink && notificationText && showNotification && (
<Notification
notificationText={notificationText}
notificationLink={notificationLink}
handleCloseNotification={() => {
setShowNotification(false);
localStorage.setItem('showNotification', 'false');
}}
/>
)}
<Routes>
<Route
element={

View File

@@ -1,6 +1,8 @@
import DocsGPT3 from './assets/cute_docsgpt3.svg';
import { useTranslation } from 'react-i18next';
import DocsGPT3 from './assets/cute_docsgpt3.svg';
import DropdownModel from './components/DropdownModel';
export default function Hero({
handleQuestion,
}: {
@@ -26,6 +28,10 @@ export default function Hero({
<span className="text-4xl font-semibold">DocsGPT</span>
<img className="mb-1 inline w-14" src={DocsGPT3} alt="docsgpt" />
</div>
{/* Model Selector */}
<div className="relative w-72">
<DropdownModel />
</div>
</div>
{/* Demo Buttons Section */}
@@ -38,7 +44,7 @@ export default function Hero({
<button
key={key}
onClick={() => handleQuestion({ question: demo.query })}
className={`border-dark-gray text-just-black hover:bg-cultured dark:border-dim-gray dark:text-chinese-white dark:hover:bg-charleston-green w-full rounded-[66px] border bg-transparent px-6 py-[14px] text-left transition-colors ${key >= 2 ? 'hidden md:block' : ''} // Show only 2 buttons on mobile`}
className={`border-dark-gray text-just-black hover:bg-cultured dark:border-dim-gray dark:text-chinese-white dark:hover:bg-charleston-green w-full rounded-[66px] border bg-transparent px-6 py-[14px] text-left transition-colors ${key >= 2 ? 'hidden md:block' : ''}`}
>
<p className="text-black-1000 dark:text-bright-gray mb-2 font-semibold">
{demo.header}

View File

@@ -9,7 +9,8 @@ import userService from './api/services/userService';
import Add from './assets/add.svg';
import DocsGPT3 from './assets/cute_docsgpt3.svg';
import Discord from './assets/discord.svg';
import Expand from './assets/expand.svg';
import PanelLeftClose from './assets/panel-left-close.svg';
import PanelLeftOpen from './assets/panel-left-open.svg';
import Github from './assets/git_nav.svg';
import Hamburger from './assets/hamburger.svg';
import openNewChat from './assets/openNewChat.svg';
@@ -302,18 +303,20 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
{
<div className="absolute top-3 left-3 z-20 hidden transition-all duration-300 ease-in-out lg:block">
<div className="flex items-center gap-3">
<button
onClick={() => {
setNavOpen(!navOpen);
}}
className="transition-transform duration-200 hover:scale-110"
>
<img
src={Expand}
alt="Toggle navigation menu"
className="m-auto transition-all duration-300 ease-in-out"
/>
</button>
{!navOpen && (
<button
onClick={() => {
setNavOpen(!navOpen);
}}
className="transition-transform duration-200 hover:scale-110"
>
<img
src={PanelLeftOpen}
alt="Open navigation menu"
className="m-auto transition-all duration-300 ease-in-out"
/>
</button>
)}
{queries?.length > 0 && (
<button
onClick={() => {
@@ -363,8 +366,8 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
}}
>
<img
src={Expand}
alt="Toggle navigation menu"
src={navOpen ? PanelLeftClose : PanelLeftOpen}
alt={navOpen ? 'Collapse sidebar' : 'Expand sidebar'}
className="m-auto transition-all duration-300 ease-in-out hover:scale-110"
/>
</button>
@@ -399,7 +402,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
{conversations?.loading && !isDeletingConversation && (
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 transform">
<img
src={isDarkTheme ? SpinnerDark : Spinner}
src={isDarkTheme ? Spinner : SpinnerDark}
className="animate-spin cursor-pointer bg-transparent"
alt="Loading conversations"
/>
@@ -408,7 +411,9 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
{recentAgents?.length > 0 ? (
<div>
<div className="mx-4 my-auto mt-2 flex h-6 items-center">
<p className="mt-1 ml-4 text-sm font-semibold">Agents</p>
<p className="mt-1 ml-4 text-sm font-semibold">
{t('navigation.agents')}
</p>
</div>
<div className="agents-container">
<div>
@@ -562,7 +567,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
<div className="flex items-center gap-1 pr-4">
<NavLink
target="_blank"
to={'https://discord.gg/WHJdfbQDR4'}
to={'https://discord.gg/vN7YFfdMpj'}
className={
'rounded-full hover:bg-gray-100 dark:hover:bg-[#28292E]'
}

View File

@@ -1,13 +1,16 @@
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
export default function PageNotFound() {
const { t } = useTranslation();
return (
<div className="dark:bg-raisin-black grid min-h-screen">
<p className="text-jet dark:bg-outer-space mx-auto my-auto mt-20 flex w-full max-w-6xl flex-col place-items-center gap-6 rounded-3xl bg-gray-100 p-6 lg:p-10 xl:p-16 dark:text-gray-100">
<h1>404</h1>
<p>The page you are looking for does not exist.</p>
<h1>{t('pageNotFound.title')}</h1>
<p>{t('pageNotFound.message')}</p>
<button className="pointer-cursor bg-blue-1000 hover:bg-blue-3000 mr-4 flex cursor-pointer items-center justify-center rounded-full px-4 py-2 text-white transition-colors duration-100">
<Link to="/">Go Back Home</Link>
<Link to="/">{t('pageNotFound.goHome')}</Link>
</button>
</p>
</div>

View File

@@ -1,14 +1,22 @@
import { useRef, useState } from 'react';
import { SyntheticEvent, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useNavigate } from 'react-router-dom';
import userService from '../api/services/userService';
import AgentImage from '../components/AgentImage';
import Duplicate from '../assets/duplicate.svg';
import Edit from '../assets/edit.svg';
import Link from '../assets/link-gray.svg';
import Monitoring from '../assets/monitoring.svg';
import Pin from '../assets/pin.svg';
import Trash from '../assets/red-trash.svg';
import ThreeDots from '../assets/three-dots.svg';
import UnPin from '../assets/unpin.svg';
import AgentImage from '../components/AgentImage';
import ContextMenu, { MenuOption } from '../components/ContextMenu';
import ConfirmationModal from '../modals/ConfirmationModal';
import { ActiveState } from '../models/misc';
import {
selectAgents,
selectToken,
setAgents,
setSelectedAgent,
@@ -18,46 +26,205 @@ import { Agent } from './types';
type AgentCardProps = {
agent: Agent;
agents: Agent[];
menuOptions?: MenuOption[];
onDelete?: (agentId: string) => void;
updateAgents?: (agents: Agent[]) => void;
section: string;
};
export default function AgentCard({
agent,
agents,
menuOptions,
onDelete,
updateAgents,
section,
}: AgentCardProps) {
const navigate = useNavigate();
const dispatch = useDispatch();
const token = useSelector(selectToken);
const userAgents = useSelector(selectAgents);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isMenuOpen, setIsMenuOpen] = useState<boolean>(false);
const [deleteConfirmation, setDeleteConfirmation] =
useState<ActiveState>('INACTIVE');
const menuRef = useRef<HTMLDivElement>(null);
const handleCardClick = () => {
if (agent.status === 'published') {
dispatch(setSelectedAgent(agent));
navigate('/');
const menuOptionsConfig: Record<string, MenuOption[]> = {
template: [
{
icon: Duplicate,
label: 'Duplicate',
onClick: (e: SyntheticEvent) => {
e.stopPropagation();
handleDuplicate();
},
variant: 'primary',
iconWidth: 18,
iconHeight: 18,
},
],
user: [
{
icon: Monitoring,
label: 'Logs',
onClick: (e: SyntheticEvent) => {
e.stopPropagation();
navigate(`/agents/logs/${agent.id}`);
},
variant: 'primary',
iconWidth: 14,
iconHeight: 14,
},
{
icon: Edit,
label: 'Edit',
onClick: (e: SyntheticEvent) => {
e.stopPropagation();
navigate(`/agents/edit/${agent.id}`);
},
variant: 'primary',
iconWidth: 14,
iconHeight: 14,
},
...(agent.status === 'published'
? [
{
icon: agent.pinned ? UnPin : Pin,
label: agent.pinned ? 'Unpin' : 'Pin agent',
onClick: (e: SyntheticEvent) => {
e.stopPropagation();
togglePin();
},
variant: 'primary' as const,
iconWidth: 18,
iconHeight: 18,
},
]
: []),
{
icon: Trash,
label: 'Delete',
onClick: (e: SyntheticEvent) => {
e.stopPropagation();
setDeleteConfirmation('ACTIVE');
},
variant: 'danger',
iconWidth: 13,
iconHeight: 13,
},
],
shared: [
{
icon: Link,
label: 'Open',
onClick: (e: SyntheticEvent) => {
e.stopPropagation();
navigate(`/agents/shared/${agent.shared_token}`);
},
variant: 'primary',
iconWidth: 12,
iconHeight: 12,
},
{
icon: agent.pinned ? UnPin : Pin,
label: agent.pinned ? 'Unpin' : 'Pin agent',
onClick: (e: SyntheticEvent) => {
e.stopPropagation();
togglePin();
},
variant: 'primary',
iconWidth: 18,
iconHeight: 18,
},
{
icon: Trash,
label: 'Remove',
onClick: (e: SyntheticEvent) => {
e.stopPropagation();
handleHideSharedAgent();
},
variant: 'danger',
iconWidth: 13,
iconHeight: 13,
},
],
};
const menuOptions = menuOptionsConfig[section] || [];
const handleClick = () => {
if (section === 'user') {
if (agent.status === 'published') {
dispatch(setSelectedAgent(agent));
navigate(`/`);
}
}
if (section === 'shared') {
navigate(`/agents/shared/${agent.shared_token}`);
}
};
const defaultDelete = async (agentId: string) => {
const response = await userService.deleteAgent(agentId, token);
if (!response.ok) throw new Error('Failed to delete agent');
const data = await response.json();
dispatch(setAgents(agents.filter((prevAgent) => prevAgent.id !== data.id)));
const togglePin = async () => {
try {
const response = await userService.togglePinAgent(agent.id ?? '', token);
if (!response.ok) throw new Error('Failed to pin agent');
const updatedAgents = agents.map((prevAgent) => {
if (prevAgent.id === agent.id)
return { ...prevAgent, pinned: !prevAgent.pinned };
return prevAgent;
});
updateAgents?.(updatedAgents);
} catch (error) {
console.error('Error:', error);
}
};
const handleHideSharedAgent = async () => {
try {
const response = await userService.removeSharedAgent(
agent.id ?? '',
token,
);
if (!response.ok) throw new Error('Failed to hide shared agent');
const updatedAgents = agents.filter(
(prevAgent) => prevAgent.id !== agent.id,
);
updateAgents?.(updatedAgents);
} catch (error) {
console.error('Error:', error);
}
};
const handleDelete = async () => {
try {
const response = await userService.deleteAgent(agent.id ?? '', token);
if (!response.ok) throw new Error('Failed to delete agent');
const updatedAgents = agents.filter(
(prevAgent) => prevAgent.id !== agent.id,
);
updateAgents?.(updatedAgents);
} catch (error) {
console.error('Error:', error);
}
};
const handleDuplicate = async () => {
try {
const response = await userService.adoptAgent(agent.id ?? '', token);
if (!response.ok) throw new Error('Failed to duplicate agent');
const data = await response.json();
if (userAgents) {
const updatedAgents = [...userAgents, data.agent];
dispatch(setAgents(updatedAgents));
} else dispatch(setAgents([data.agent]));
} catch (error) {
console.error('Error:', error);
}
};
return (
<div
className={`relative flex h-44 w-48 flex-col justify-between rounded-[1.2rem] bg-[#F6F6F6] px-6 py-5 hover:bg-[#ECECEC] dark:bg-[#383838] dark:hover:bg-[#383838]/80 ${
agent.status === 'published' ? 'cursor-pointer' : ''
}`}
onClick={handleCardClick}
className={`relative flex h-44 w-full flex-col justify-between rounded-[1.2rem] bg-[#F6F6F6] px-6 py-5 hover:bg-[#ECECEC] md:w-48 dark:bg-[#383838] dark:hover:bg-[#383838]/80 ${agent.status === 'published' && 'cursor-pointer'}`}
onClick={(e) => {
e.stopPropagation();
handleClick();
}}
>
<div
ref={menuRef}
@@ -67,19 +234,16 @@ export default function AgentCard({
}}
className="absolute top-4 right-4 z-10 cursor-pointer"
>
<img src={ThreeDots} alt="options" className="h-[19px] w-[19px]" />
{menuOptions && (
<ContextMenu
isOpen={isMenuOpen}
setIsOpen={setIsMenuOpen}
options={menuOptions}
anchorRef={menuRef}
position="top-right"
offset={{ x: 0, y: 0 }}
/>
)}
<img src={ThreeDots} alt={'use-agent'} className="h-[19px] w-[19px]" />
<ContextMenu
isOpen={isMenuOpen}
setIsOpen={setIsMenuOpen}
options={menuOptions}
anchorRef={menuRef}
position="bottom-right"
offset={{ x: 0, y: 0 }}
/>
</div>
<div className="w-full">
<div className="flex w-full items-center gap-1 px-1">
<AgentImage
@@ -88,9 +252,7 @@ export default function AgentCard({
className="h-7 w-7 rounded-full object-contain"
/>
{agent.status === 'draft' && (
<p className="text-xs text-black opacity-50 dark:text-[#E0E0E0]">
(Draft)
</p>
<p className="text-xs text-black opacity-50 dark:text-[#E0E0E0]">{`(Draft)`}</p>
)}
</div>
<div className="mt-2">
@@ -105,14 +267,13 @@ export default function AgentCard({
</p>
</div>
</div>
<ConfirmationModal
message="Are you sure you want to delete this agent?"
modalState={deleteConfirmation}
setModalState={setDeleteConfirmation}
submitLabel="Delete"
handleSubmit={() => {
onDelete ? onDelete(agent.id || '') : defaultDelete(agent.id || '');
handleDelete();
setDeleteConfirmation('INACTIVE');
}}
cancelLabel="Cancel"

View File

@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { useNavigate, useParams } from 'react-router-dom';
@@ -11,6 +12,7 @@ import Logs from '../settings/Logs';
import { Agent } from './types';
export default function AgentLogs() {
const { t } = useTranslation();
const navigate = useNavigate();
const { agentId } = useParams();
const token = useSelector(selectToken);
@@ -45,12 +47,12 @@ export default function AgentLogs() {
<img src={ArrowLeft} alt="left-arrow" className="h-3 w-3" />
</button>
<p className="text-eerie-black dark:text-bright-gray mt-px text-sm font-semibold">
Back to all agents
{t('agents.backToAll')}
</p>
</div>
<div className="mt-5 flex w-full flex-wrap items-center justify-between gap-2 px-4">
<h1 className="text-eerie-black m-0 text-[32px] font-bold md:text-[40px] dark:text-white">
Agent Logs
{t('agents.logs.title')}
</h1>
</div>
<div className="mt-6 flex flex-col gap-3 px-4">
@@ -59,9 +61,10 @@ export default function AgentLogs() {
<p className="text-[#28292E] dark:text-[#E0E0E0]">{agent.name}</p>
<p className="text-xs text-[#28292E] dark:text-[#E0E0E0]/40">
{agent.last_used_at
? 'Last used at ' +
? t('agents.logs.lastUsedAt') +
' ' +
new Date(agent.last_used_at).toLocaleString()
: 'No usage history'}
: t('agents.logs.noUsageHistory')}
</p>
</div>
)}
@@ -79,7 +82,9 @@ export default function AgentLogs() {
<Spinner />
</div>
) : (
agent && <Logs agentId={agent.id} tableHeader="Agent endpoint logs" />
agent && (
<Logs agentId={agent.id} tableHeader={t('agents.logs.tableHeader')} />
)
)}
</div>
);

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import MessageInput from '../components/MessageInput';
@@ -17,6 +18,7 @@ import { selectSelectedAgent } from '../preferences/preferenceSlice';
import { AppDispatch } from '../store';
export default function AgentPreview() {
const { t } = useTranslation();
const dispatch = useDispatch<AppDispatch>();
const queries = useSelector(selectPreviewQueries);
@@ -109,18 +111,18 @@ export default function AgentPreview() {
} else setLastQueryReturnedErr(false);
}, [queries]);
return (
<div>
<div className="dark:bg-raisin-black flex h-full flex-col items-center justify-between gap-2 overflow-y-hidden">
<div className="h-[512px] w-full overflow-y-auto">
<ConversationMessages
handleQuestion={handleQuestion}
handleQuestionSubmission={handleQuestionSubmission}
queries={queries}
status={status}
showHeroOnEmpty={false}
/>
</div>
<div className="flex w-[95%] max-w-[1500px] flex-col items-center gap-4 pb-2 md:w-9/12 lg:w-8/12 xl:w-8/12 2xl:w-6/12">
<div className="relative h-full w-full">
<div className="scrollbar-thin absolute inset-0 bottom-[180px] overflow-hidden px-4 pt-4 [&>div>div]:!w-full [&>div>div]:!max-w-none">
<ConversationMessages
handleQuestion={handleQuestion}
handleQuestionSubmission={handleQuestionSubmission}
queries={queries}
status={status}
showHeroOnEmpty={false}
/>
</div>
<div className="absolute right-0 bottom-0 left-0 flex w-full flex-col gap-4 pb-2">
<div className="w-full px-4">
<MessageInput
onSubmit={(text) => handleQuestionSubmission(text)}
loading={status === 'loading'}
@@ -128,11 +130,10 @@ export default function AgentPreview() {
showToolButton={selectedAgent ? false : true}
autoFocus={false}
/>
<p className="text-gray-4000 dark:text-sonic-silver w-full self-center bg-transparent pt-2 text-center text-xs md:inline">
This is a preview of the agent. You can publish it to start using it
in conversations.
</p>
</div>
<p className="text-gray-4000 dark:text-sonic-silver w-full bg-transparent text-center text-xs md:inline">
{t('agents.preview.testMessage')}
</p>
</div>
</div>
);

View File

@@ -0,0 +1,138 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { useNavigate } from 'react-router-dom';
import Spinner from '../components/Spinner';
import {
setConversation,
updateConversationId,
} from '../conversation/conversationSlice';
import {
selectSelectedAgent,
selectToken,
setSelectedAgent,
} from '../preferences/preferenceSlice';
import AgentCard from './AgentCard';
import { agentSectionsConfig } from './agents.config';
import { Agent } from './types';
export default function AgentsList() {
const { t } = useTranslation();
const dispatch = useDispatch();
const token = useSelector(selectToken);
const selectedAgent = useSelector(selectSelectedAgent);
useEffect(() => {
dispatch(setConversation([]));
dispatch(
updateConversationId({
query: { conversationId: null },
}),
);
if (selectedAgent) dispatch(setSelectedAgent(null));
}, [token]);
return (
<div className="p-4 md:p-12">
<h1 className="text-eerie-black mb-0 text-[32px] font-bold lg:text-[40px] dark:text-[#E0E0E0]">
{t('agents.title')}
</h1>
<p className="dark:text-gray-4000 mt-5 text-[15px] text-[#71717A]">
{t('agents.description')}
</p>
{agentSectionsConfig.map((sectionConfig) => (
<AgentSection key={sectionConfig.id} config={sectionConfig} />
))}
</div>
);
}
function AgentSection({
config,
}: {
config: (typeof agentSectionsConfig)[number];
}) {
const { t } = useTranslation();
const navigate = useNavigate();
const dispatch = useDispatch();
const token = useSelector(selectToken);
const agents = useSelector(config.selectData);
const [loading, setLoading] = useState(true);
const updateAgents = (updatedAgents: Agent[]) => {
dispatch(config.updateAction(updatedAgents));
};
useEffect(() => {
const getAgents = async () => {
setLoading(true);
try {
const response = await config.fetchAgents(token);
if (!response.ok)
throw new Error(`Failed to fetch ${config.id} agents`);
const data = await response.json();
dispatch(config.updateAction(data));
} catch (error) {
console.error(`Error fetching ${config.id} agents:`, error);
dispatch(config.updateAction([]));
} finally {
setLoading(false);
}
};
getAgents();
}, [token, config, dispatch]);
return (
<div className="mt-8 flex flex-col gap-4">
<div className="flex w-full items-center justify-between">
<div className="flex flex-col gap-2">
<h2 className="text-[18px] font-semibold text-[#18181B] dark:text-[#E0E0E0]">
{t(`agents.sections.${config.id}.title`)}
</h2>
<p className="text-[13px] text-[#71717A]">
{t(`agents.sections.${config.id}.description`)}
</p>
</div>
{config.showNewAgentButton && (
<button
className="bg-purple-30 hover:bg-violets-are-blue rounded-full px-4 py-2 text-sm text-white"
onClick={() => navigate('/agents/new')}
>
{t('agents.newAgent')}
</button>
)}
</div>
<div>
{loading ? (
<div className="flex h-72 w-full items-center justify-center">
<Spinner />
</div>
) : agents && agents.length > 0 ? (
<div className="grid grid-cols-1 gap-4 sm:flex sm:flex-wrap">
{agents.map((agent) => (
<AgentCard
key={agent.id}
agent={agent}
agents={agents}
updateAgents={updateAgents}
section={config.id}
/>
))}
</div>
) : (
<div className="flex h-72 w-full flex-col items-center justify-center gap-3 text-base text-[#18181B] dark:text-[#E0E0E0]">
<p>{t(`agents.sections.${config.id}.emptyState`)}</p>
{config.showNewAgentButton && (
<button
className="bg-purple-30 hover:bg-violets-are-blue ml-2 rounded-full px-4 py-2 text-sm text-white"
onClick={() => navigate('/agents/new')}
>
{t('agents.newAgent')}
</button>
)}
</div>
)}
</div>
</div>
);
}

View File

@@ -1,8 +1,10 @@
import isEqual from 'lodash/isEqual';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { useNavigate, useParams } from 'react-router-dom';
import modelService from '../api/services/modelService';
import userService from '../api/services/userService';
import ArrowLeft from '../assets/arrow-left.svg';
import SourceIcon from '../assets/source.svg';
@@ -23,13 +25,15 @@ import PromptsModal from '../preferences/PromptsModal';
import Prompts from '../settings/Prompts';
import { UserToolType } from '../settings/types';
import AgentPreview from './AgentPreview';
import { Agent } from './types';
import { Agent, ToolSummary } from './types';
import type { Model } from '../models/types';
const embeddingsName =
import.meta.env.VITE_EMBEDDINGS_NAME ||
'huggingface_sentence-transformers/all-mpnet-base-v2';
export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
const { t } = useTranslation();
const navigate = useNavigate();
const dispatch = useDispatch();
const { agentId } = useParams();
@@ -53,18 +57,27 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
agent_type: 'classic',
status: '',
json_schema: undefined,
limited_token_mode: false,
token_limit: undefined,
limited_request_mode: false,
request_limit: undefined,
models: [],
default_model_id: '',
});
const [imageFile, setImageFile] = useState<File | null>(null);
const [prompts, setPrompts] = useState<
{ name: string; id: string; type: string }[]
>([]);
const [userTools, setUserTools] = useState<OptionType[]>([]);
const [availableModels, setAvailableModels] = useState<Model[]>([]);
const [isSourcePopupOpen, setIsSourcePopupOpen] = useState(false);
const [isToolsPopupOpen, setIsToolsPopupOpen] = useState(false);
const [isModelsPopupOpen, setIsModelsPopupOpen] = useState(false);
const [selectedSourceIds, setSelectedSourceIds] = useState<
Set<string | number>
>(new Set());
const [selectedToolIds, setSelectedToolIds] = useState<Set<string | number>>(
const [selectedTools, setSelectedTools] = useState<ToolSummary[]>([]);
const [selectedModelIds, setSelectedModelIds] = useState<Set<string>>(
new Set(),
);
const [deleteConfirmation, setDeleteConfirmation] =
@@ -76,16 +89,18 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
const [publishLoading, setPublishLoading] = useState(false);
const [jsonSchemaText, setJsonSchemaText] = useState('');
const [jsonSchemaValid, setJsonSchemaValid] = useState(true);
const [isJsonSchemaExpanded, setIsJsonSchemaExpanded] = useState(false);
const [isAdvancedSectionExpanded, setIsAdvancedSectionExpanded] =
useState(false);
const initialAgentRef = useRef<Agent | null>(null);
const sourceAnchorButtonRef = useRef<HTMLButtonElement>(null);
const toolAnchorButtonRef = useRef<HTMLButtonElement>(null);
const modelAnchorButtonRef = useRef<HTMLButtonElement>(null);
const modeConfig = {
new: {
heading: 'New Agent',
buttonText: 'Publish',
heading: t('agents.form.headings.new'),
buttonText: t('agents.form.buttons.publish'),
showDelete: false,
showSaveDraft: true,
showLogs: false,
@@ -93,8 +108,8 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
trackChanges: false,
},
edit: {
heading: 'Edit Agent',
buttonText: 'Save',
heading: t('agents.form.headings.edit'),
buttonText: t('agents.form.buttons.save'),
showDelete: true,
showSaveDraft: false,
showLogs: true,
@@ -102,8 +117,8 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
trackChanges: true,
},
draft: {
heading: 'New Agent (Draft)',
buttonText: 'Publish',
heading: t('agents.form.headings.draft'),
buttonText: t('agents.form.buttons.publish'),
showDelete: true,
showSaveDraft: true,
showLogs: false,
@@ -113,8 +128,8 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
};
const chunks = ['0', '2', '4', '6', '8', '10'];
const agentTypes = [
{ label: 'Classic', value: 'classic' },
{ label: 'ReAct', value: 'react' },
{ label: t('agents.form.agentTypes.classic'), value: 'classic' },
{ label: t('agents.form.agentTypes.react'), value: 'react' },
];
const isPublishable = () => {
@@ -193,6 +208,22 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
formData.append('agent_type', agent.agent_type);
formData.append('status', 'draft');
if (agent.limited_token_mode && agent.token_limit) {
formData.append('limited_token_mode', 'True');
formData.append('token_limit', agent.token_limit.toString());
} else {
formData.append('limited_token_mode', 'False');
formData.append('token_limit', '0');
}
if (agent.limited_request_mode && agent.request_limit) {
formData.append('limited_request_mode', 'True');
formData.append('request_limit', agent.request_limit.toString());
} else {
formData.append('limited_request_mode', 'False');
formData.append('request_limit', '0');
}
if (imageFile) formData.append('image', imageFile);
if (agent.tools && agent.tools.length > 0)
@@ -203,6 +234,13 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
formData.append('json_schema', JSON.stringify(agent.json_schema));
}
if (agent.models && agent.models.length > 0) {
formData.append('models', JSON.stringify(agent.models));
}
if (agent.default_model_id) {
formData.append('default_model_id', agent.default_model_id);
}
try {
setDraftLoading(true);
const response =
@@ -282,6 +320,30 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
formData.append('json_schema', JSON.stringify(agent.json_schema));
}
// Always send the limited mode fields
if (agent.limited_token_mode && agent.token_limit) {
formData.append('limited_token_mode', 'True');
formData.append('token_limit', agent.token_limit.toString());
} else {
formData.append('limited_token_mode', 'False');
formData.append('token_limit', '0');
}
if (agent.limited_request_mode && agent.request_limit) {
formData.append('limited_request_mode', 'True');
formData.append('request_limit', agent.request_limit.toString());
} else {
formData.append('limited_request_mode', 'False');
formData.append('request_limit', '0');
}
if (agent.models && agent.models.length > 0) {
formData.append('models', JSON.stringify(agent.models));
}
if (agent.default_model_id) {
formData.append('default_model_id', agent.default_model_id);
}
try {
setPublishLoading(true);
const response =
@@ -337,7 +399,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
const data = await response.json();
const tools: OptionType[] = data.tools.map((tool: UserToolType) => ({
id: tool.id,
label: tool.displayName,
label: tool.customName ? tool.customName : tool.displayName,
icon: `/toolIcons/tool_${tool.name}.svg`,
}));
setUserTools(tools);
@@ -350,8 +412,16 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
const data = await response.json();
setPrompts(data);
};
const getModels = async () => {
const response = await modelService.getModels(null);
if (!response.ok) throw new Error('Failed to fetch models');
const data = await response.json();
const transformed = modelService.transformModels(data.models || []);
setAvailableModels(transformed);
};
getTools();
getPrompts();
getModels();
}, [token]);
// Auto-select default source if none selected
@@ -410,7 +480,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
setSelectedSourceIds(new Set([data.retriever]));
}
if (data.tools) setSelectedToolIds(new Set(data.tools));
if (data.tool_details) setSelectedTools(data.tool_details);
if (data.status === 'draft') setEffectiveMode('draft');
if (data.json_schema) {
const jsonText = JSON.stringify(data.json_schema, null, 2);
@@ -424,6 +494,34 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
}
}, [agentId, mode, token]);
useEffect(() => {
if (agent.models && agent.models.length > 0 && availableModels.length > 0) {
const agentModelIds = new Set(agent.models);
if (agentModelIds.size > 0 && selectedModelIds.size === 0) {
setSelectedModelIds(agentModelIds);
}
}
}, [agent.models, availableModels.length]);
useEffect(() => {
const modelsArray = Array.from(selectedModelIds);
if (modelsArray.length > 0) {
setAgent((prev) => ({
...prev,
models: modelsArray,
default_model_id: modelsArray.includes(prev.default_model_id || '')
? prev.default_model_id
: modelsArray[0],
}));
} else {
setAgent((prev) => ({
...prev,
models: [],
default_model_id: '',
}));
}
}, [selectedModelIds]);
useEffect(() => {
const selectedSources = Array.from(selectedSourceIds)
.map((id) =>
@@ -480,16 +578,13 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
}, [selectedSourceIds]);
useEffect(() => {
const selectedTool = Array.from(selectedToolIds).map((id) =>
userTools.find((tool) => tool.id === id),
);
setAgent((prev) => ({
...prev,
tools: selectedTool
tools: Array.from(selectedTools)
.map((tool) => tool?.id)
.filter((id): id is string => typeof id === 'string'),
}));
}, [selectedToolIds]);
}, [selectedTools]);
useEffect(() => {
if (isPublishable()) dispatch(setSelectedAgent(agent));
@@ -514,7 +609,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
setHasChanges(isChanged);
}, [agent, dispatch, effectiveMode, imageFile, jsonSchemaText]);
return (
<div className="p-4 md:p-12">
<div className="flex flex-col px-4 pt-4 pb-2 max-[1179px]:min-h-[100dvh] min-[1180px]:h-[100dvh] md:px-12 md:pt-12 md:pb-3">
<div className="flex items-center gap-3 px-4">
<button
className="rounded-full border p-3 text-sm text-gray-400 dark:border-0 dark:bg-[#28292D] dark:text-gray-500 dark:hover:bg-[#2E2F34]"
@@ -523,7 +618,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
<img src={ArrowLeft} alt="left-arrow" className="h-3 w-3" />
</button>
<p className="text-eerie-black dark:text-bright-gray mt-px text-sm font-semibold">
Back to all agents
{t('agents.backToAll')}
</p>
</div>
<div className="mt-5 flex w-full flex-wrap items-center justify-between gap-2 px-4">
@@ -535,7 +630,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
className="text-purple-30 dark:text-light-gray mr-4 rounded-3xl py-2 text-sm font-medium dark:bg-transparent"
onClick={handleCancel}
>
Cancel
{t('agents.form.buttons.cancel')}
</button>
{modeConfig[effectiveMode].showDelete && agent.id && (
<button
@@ -543,7 +638,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
onClick={() => setDeleteConfirmation('ACTIVE')}
>
<span className="block h-4 w-4 bg-[url('/src/assets/red-trash.svg')] bg-contain bg-center bg-no-repeat transition-all group-hover:bg-[url('/src/assets/white-trash.svg')]" />
Delete
{t('agents.form.buttons.delete')}
</button>
)}
{modeConfig[effectiveMode].showSaveDraft && (
@@ -558,7 +653,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
{draftLoading ? (
<Spinner size="small" color="#976af3" />
) : (
'Save Draft'
t('agents.form.buttons.saveDraft')
)}
</span>
</button>
@@ -569,7 +664,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
onClick={() => navigate(`/agents/logs/${agent.id}`)}
>
<span className="block h-5 w-5 bg-[url('/src/assets/monitoring-purple.svg')] bg-contain bg-center bg-no-repeat transition-all group-hover:bg-[url('/src/assets/monitoring-white.svg')]" />
Logs
{t('agents.form.buttons.logs')}
</button>
)}
{modeConfig[effectiveMode].showAccessDetails && (
@@ -577,7 +672,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
className="hover:bg-vi</button>olets-are-blue border-violets-are-blue text-violets-are-blue hover:bg-violets-are-blue rounded-3xl border border-solid px-5 py-2 text-sm font-medium transition-colors hover:text-white"
onClick={() => setAgentDetails('ACTIVE')}
>
Access Details
{t('agents.form.buttons.accessDetails')}
</button>
)}
<button
@@ -595,20 +690,22 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
</button>
</div>
</div>
<div className="mt-5 flex w-full grid-cols-5 flex-col gap-10 min-[1180px]:grid min-[1180px]:gap-5">
<div className="col-span-2 flex flex-col gap-5">
<div className="rounded-[30px] bg-[#F6F6F6] px-6 py-3 dark:bg-[#383838] dark:text-[#E0E0E0]">
<h2 className="text-lg font-semibold">Meta</h2>
<div className="mt-3 flex w-full flex-1 grid-cols-5 flex-col gap-10 rounded-[30px] bg-[#F6F6F6] p-5 max-[1179px]:overflow-visible min-[1180px]:grid min-[1180px]:gap-5 min-[1180px]:overflow-hidden dark:bg-[#383838]">
<div className="scrollbar-thin col-span-2 flex flex-col gap-5 max-[1179px]:overflow-visible min-[1180px]:max-h-full min-[1180px]:overflow-y-auto min-[1180px]:pr-3">
<div className="dark:bg-raisin-black rounded-[30px] bg-white px-6 py-3 dark:text-[#E0E0E0]">
<h2 className="text-lg font-semibold">
{t('agents.form.sections.meta')}
</h2>
<input
className="border-silver text-jet dark:bg-raisin-black dark:text-bright-gray dark:placeholder:text-silver mt-3 w-full rounded-3xl border bg-white px-5 py-3 text-sm outline-hidden placeholder:text-gray-400 dark:border-[#7E7E7E]"
type="text"
value={agent.name}
placeholder="Agent name"
placeholder={t('agents.form.placeholders.agentName')}
onChange={(e) => setAgent({ ...agent, name: e.target.value })}
/>
<textarea
className="border-silver text-jet dark:bg-raisin-black dark:text-bright-gray dark:placeholder:text-silver mt-3 h-32 w-full rounded-xl border bg-white px-5 py-4 text-sm outline-hidden placeholder:text-gray-400 dark:border-[#7E7E7E]"
placeholder="Describe your agent"
placeholder={t('agents.form.placeholders.describeAgent')}
value={agent.description}
onChange={(e) =>
setAgent({ ...agent, description: e.target.value })
@@ -621,17 +718,22 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
onUpload={handleUpload}
onRemove={() => setImageFile(null)}
uploadText={[
{ text: 'Click to upload', colorClass: 'text-[#7D54D1]' },
{
text: ' or drag and drop',
text: t('agents.form.upload.clickToUpload'),
colorClass: 'text-[#7D54D1]',
},
{
text: t('agents.form.upload.dragAndDrop'),
colorClass: 'text-[#525252]',
},
]}
/>
</div>
</div>
<div className="rounded-[30px] bg-[#F6F6F6] px-6 py-3 dark:bg-[#383838] dark:text-[#E0E0E0]">
<h2 className="text-lg font-semibold">Source</h2>
<div className="dark:bg-raisin-black rounded-[30px] bg-white px-6 py-3 dark:text-[#E0E0E0]">
<h2 className="text-lg font-semibold">
{t('agents.form.sections.source')}
</h2>
<div className="mt-3">
<div className="flex flex-wrap items-center gap-1">
<button
@@ -645,18 +747,20 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
>
{selectedSourceIds.size > 0
? Array.from(selectedSourceIds)
.map(
(id) =>
sourceDocs?.find(
(source) =>
source.id === id ||
source.name === id ||
source.retriever === id,
)?.name,
)
.map((id) => {
const matchedDoc = sourceDocs?.find(
(source) =>
source.id === id ||
source.name === id ||
source.retriever === id,
);
return (
matchedDoc?.name || t('agents.form.externalKb')
);
})
.filter(Boolean)
.join(', ')
: 'Select sources'}
: t('agents.form.placeholders.selectSources')}
</button>
<MultiSelectPopup
isOpen={isSourcePopupOpen}
@@ -700,9 +804,13 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
setSelectedSourceIds(newSelectedIds);
}
}}
title="Select Sources"
searchPlaceholder="Search sources..."
noOptionsMessage="No sources available"
title={t('agents.form.sourcePopup.title')}
searchPlaceholder={t(
'agents.form.sourcePopup.searchPlaceholder',
)}
noOptionsMessage={t(
'agents.form.sourcePopup.noOptionsMessage',
)}
/>
</div>
<div className="mt-3">
@@ -717,14 +825,14 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
border="border"
buttonClassName="bg-white dark:bg-[#222327] border-silver dark:border-[#7E7E7E]"
optionsClassName="bg-white dark:bg-[#383838] border-silver dark:border-[#7E7E7E]"
placeholder="Chunks per query"
placeholder={t('agents.form.placeholders.chunksPerQuery')}
placeholderClassName="text-gray-400 dark:text-silver"
contentSize="text-sm"
/>
</div>
</div>
</div>
<div className="rounded-[30px] bg-[#F6F6F6] px-6 py-3 dark:bg-[#383838] dark:text-[#E0E0E0]">
<div className="dark:bg-raisin-black rounded-[30px] bg-white px-6 py-3 dark:text-[#E0E0E0]">
<div className="flex flex-wrap items-end gap-1">
<div className="min-w-20 grow basis-full sm:basis-0">
<Prompts
@@ -737,7 +845,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
setAgent({ ...agent, prompt_id: id })
}
setPrompts={setPrompts}
title="Prompt"
title={t('agents.form.sections.prompt')}
titleClassName="text-lg font-semibold dark:text-[#E0E0E0]"
showAddButton={false}
dropdownProps={{
@@ -757,48 +865,60 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
className="border-violets-are-blue text-violets-are-blue hover:bg-violets-are-blue w-20 shrink-0 basis-full rounded-3xl border-2 border-solid px-5 py-[11px] text-sm transition-colors hover:text-white sm:basis-auto"
onClick={() => setAddPromptModal('ACTIVE')}
>
Add
{t('agents.form.buttons.add')}
</button>
</div>
</div>
<div className="rounded-[30px] bg-[#F6F6F6] px-6 py-3 dark:bg-[#383838] dark:text-[#E0E0E0]">
<h2 className="text-lg font-semibold">Tools</h2>
<div className="dark:bg-raisin-black rounded-[30px] bg-white px-6 py-3 dark:text-[#E0E0E0]">
<h2 className="text-lg font-semibold">
{t('agents.form.sections.tools')}
</h2>
<div className="mt-3 flex flex-wrap items-center gap-1">
<button
ref={toolAnchorButtonRef}
onClick={() => setIsToolsPopupOpen(!isToolsPopupOpen)}
className={`border-silver dark:bg-raisin-black w-full truncate rounded-3xl border bg-white px-5 py-3 text-left text-sm dark:border-[#7E7E7E] ${
selectedToolIds.size > 0
selectedTools.length > 0
? 'text-jet dark:text-bright-gray'
: 'dark:text-silver text-gray-400'
}`}
>
{selectedToolIds.size > 0
? Array.from(selectedToolIds)
.map(
(id) => userTools.find((tool) => tool.id === id)?.label,
)
{selectedTools.length > 0
? selectedTools
.map((tool) => tool.display_name || tool.name)
.filter(Boolean)
.join(', ')
: 'Select tools'}
: t('agents.form.placeholders.selectTools')}
</button>
<MultiSelectPopup
isOpen={isToolsPopupOpen}
onClose={() => setIsToolsPopupOpen(false)}
anchorRef={toolAnchorButtonRef}
options={userTools}
selectedIds={selectedToolIds}
selectedIds={new Set(selectedTools.map((tool) => tool.id))}
onSelectionChange={(newSelectedIds: Set<string | number>) =>
setSelectedToolIds(newSelectedIds)
setSelectedTools(
userTools
.filter((tool) => newSelectedIds.has(tool.id))
.map((tool) => ({
id: String(tool.id),
name: tool.label,
display_name: tool.label,
})),
)
}
title="Select Tools"
searchPlaceholder="Search tools..."
noOptionsMessage="No tools available"
title={t('agents.form.toolsPopup.title')}
searchPlaceholder={t(
'agents.form.toolsPopup.searchPlaceholder',
)}
noOptionsMessage={t('agents.form.toolsPopup.noOptionsMessage')}
/>
</div>
</div>
<div className="rounded-[30px] bg-[#F6F6F6] px-6 py-3 dark:bg-[#383838] dark:text-[#E0E0E0]">
<h2 className="text-lg font-semibold">Agent type</h2>
<div className="dark:bg-raisin-black rounded-[30px] bg-white px-6 py-3 dark:text-[#E0E0E0]">
<h2 className="text-lg font-semibold">
{t('agents.form.sections.agentType')}
</h2>
<div className="mt-3">
<Dropdown
options={agentTypes}
@@ -816,24 +936,104 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
border="border"
buttonClassName="bg-white dark:bg-[#222327] border-silver dark:border-[#7E7E7E]"
optionsClassName="bg-white dark:bg-[#383838] border-silver dark:border-[#7E7E7E]"
placeholder="Select type"
placeholder={t('agents.form.placeholders.selectType')}
placeholderClassName="text-gray-400 dark:text-silver"
contentSize="text-sm"
/>
</div>
</div>
<div className="rounded-[30px] bg-[#F6F6F6] px-6 py-3 dark:bg-[#383838] dark:text-[#E0E0E0]">
<div className="dark:bg-raisin-black rounded-[30px] bg-white px-6 py-3 dark:text-[#E0E0E0]">
<h2 className="text-lg font-semibold">
{t('agents.form.sections.models')}
</h2>
<div className="mt-3 flex flex-col gap-3">
<button
ref={modelAnchorButtonRef}
onClick={() => setIsModelsPopupOpen(!isModelsPopupOpen)}
className={`border-silver dark:bg-raisin-black w-full truncate rounded-3xl border bg-white px-5 py-3 text-left text-sm dark:border-[#7E7E7E] ${
selectedModelIds.size > 0
? 'text-jet dark:text-bright-gray'
: 'dark:text-silver text-gray-400'
}`}
>
{selectedModelIds.size > 0
? availableModels
.filter((m) => selectedModelIds.has(m.id))
.map((m) => m.display_name)
.join(', ')
: t('agents.form.placeholders.selectModels')}
</button>
<MultiSelectPopup
isOpen={isModelsPopupOpen}
onClose={() => setIsModelsPopupOpen(false)}
anchorRef={modelAnchorButtonRef}
options={availableModels.map((model) => ({
id: model.id,
label: model.display_name,
}))}
selectedIds={selectedModelIds}
onSelectionChange={(newSelectedIds: Set<string | number>) =>
setSelectedModelIds(
new Set(Array.from(newSelectedIds).map(String)),
)
}
title={t('agents.form.modelsPopup.title')}
searchPlaceholder={t(
'agents.form.modelsPopup.searchPlaceholder',
)}
noOptionsMessage={t('agents.form.modelsPopup.noOptionsMessage')}
/>
{selectedModelIds.size > 0 && (
<div>
<label className="mb-2 block text-sm font-medium">
{t('agents.form.labels.defaultModel')}
</label>
<Dropdown
options={availableModels
.filter((m) => selectedModelIds.has(m.id))
.map((m) => ({
label: m.display_name,
value: m.id,
}))}
selectedValue={
availableModels.find(
(m) => m.id === agent.default_model_id,
)?.display_name || null
}
onSelect={(option: { label: string; value: string }) =>
setAgent({ ...agent, default_model_id: option.value })
}
size="w-full"
rounded="3xl"
border="border"
buttonClassName="bg-white dark:bg-[#222327] border-silver dark:border-[#7E7E7E]"
optionsClassName="bg-white dark:bg-[#383838] border-silver dark:border-[#7E7E7E]"
placeholder={t(
'agents.form.placeholders.selectDefaultModel',
)}
placeholderClassName="text-gray-400 dark:text-silver"
contentSize="text-sm"
/>
</div>
)}
</div>
</div>
<div className="dark:bg-raisin-black rounded-[30px] bg-white px-6 py-3 dark:text-[#E0E0E0]">
<button
onClick={() => setIsJsonSchemaExpanded(!isJsonSchemaExpanded)}
onClick={() =>
setIsAdvancedSectionExpanded(!isAdvancedSectionExpanded)
}
className="flex w-full items-center justify-between text-left focus:outline-none"
>
<div>
<h2 className="text-lg font-semibold">Advanced</h2>
<h2 className="text-lg font-semibold">
{t('agents.form.sections.advanced')}
</h2>
</div>
<div className="ml-4 flex items-center">
<svg
className={`h-5 w-5 transform transition-transform duration-200 ${
isJsonSchemaExpanded ? 'rotate-180' : ''
isAdvancedSectionExpanded ? 'rotate-180' : ''
}`}
fill="none"
stroke="currentColor"
@@ -848,12 +1048,14 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
</svg>
</div>
</button>
{isJsonSchemaExpanded && (
{isAdvancedSectionExpanded && (
<div className="mt-3">
<div>
<h2 className="text-sm font-medium">JSON response schema</h2>
<h2 className="text-sm font-medium">
{t('agents.form.advanced.jsonSchema')}
</h2>
<p className="mt-1 text-xs text-gray-600 dark:text-gray-400">
Define a JSON schema to enforce structured output format
{t('agents.form.advanced.jsonSchemaDescription')}
</p>
</div>
<textarea
@@ -887,29 +1089,147 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
}`}
/>
{jsonSchemaValid
? 'Valid JSON'
: 'Invalid JSON - fix to enable saving'}
? t('agents.form.advanced.validJson')
: t('agents.form.advanced.invalidJson')}
</div>
)}
<div className="mt-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-sm font-medium">
{t('agents.form.advanced.tokenLimiting')}
</h2>
<p className="mt-1 text-xs text-gray-600 dark:text-gray-400">
{t('agents.form.advanced.tokenLimitingDescription')}
</p>
</div>
<button
onClick={() => {
const newTokenMode = !agent.limited_token_mode;
setAgent({
...agent,
limited_token_mode: newTokenMode,
limited_request_mode: newTokenMode
? false
: agent.limited_request_mode,
});
}}
className={`relative h-6 w-11 rounded-full transition-colors ${
agent.limited_token_mode
? 'bg-purple-30'
: 'bg-gray-300 dark:bg-gray-600'
}`}
>
<span
className={`absolute top-0.5 h-5 w-5 transform rounded-full bg-white transition-transform ${
agent.limited_token_mode ? '' : '-translate-x-5'
}`}
/>
</button>
</div>
<input
type="number"
min="0"
value={agent.token_limit || ''}
onChange={(e) =>
setAgent({
...agent,
token_limit: e.target.value
? parseInt(e.target.value)
: undefined,
})
}
disabled={!agent.limited_token_mode}
placeholder={t('agents.form.placeholders.enterTokenLimit')}
className={`border-silver text-jet dark:bg-raisin-black dark:text-bright-gray dark:placeholder:text-silver mt-2 w-full rounded-3xl border bg-white px-5 py-3 text-sm outline-hidden placeholder:text-gray-400 dark:border-[#7E7E7E] ${
!agent.limited_token_mode
? 'cursor-not-allowed opacity-50'
: ''
}`}
/>
</div>
<div className="mt-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-sm font-medium">
{t('agents.form.advanced.requestLimiting')}
</h2>
<p className="mt-1 text-xs text-gray-600 dark:text-gray-400">
{t('agents.form.advanced.requestLimitingDescription')}
</p>
</div>
<button
onClick={() => {
const newRequestMode = !agent.limited_request_mode;
setAgent({
...agent,
limited_request_mode: newRequestMode,
limited_token_mode: newRequestMode
? false
: agent.limited_token_mode,
});
}}
className={`relative h-6 w-11 rounded-full transition-colors ${
agent.limited_request_mode
? 'bg-purple-30'
: 'bg-gray-300 dark:bg-gray-600'
}`}
>
<span
className={`absolute top-0.5 h-5 w-5 transform rounded-full bg-white transition-transform ${
agent.limited_request_mode ? '' : '-translate-x-5'
}`}
/>
</button>
</div>
<input
type="number"
min="0"
value={agent.request_limit || ''}
onChange={(e) =>
setAgent({
...agent,
request_limit: e.target.value
? parseInt(e.target.value)
: undefined,
})
}
disabled={!agent.limited_request_mode}
placeholder={t(
'agents.form.placeholders.enterRequestLimit',
)}
className={`border-silver text-jet dark:bg-raisin-black dark:text-bright-gray dark:placeholder:text-silver mt-2 w-full rounded-3xl border bg-white px-5 py-3 text-sm outline-hidden placeholder:text-gray-400 dark:border-[#7E7E7E] ${
!agent.limited_request_mode
? 'cursor-not-allowed opacity-50'
: ''
}`}
/>
</div>
</div>
)}
</div>
</div>
<div className="col-span-3 flex flex-col gap-3 rounded-[30px] bg-[#F6F6F6] px-6 py-3 dark:bg-[#383838] dark:text-[#E0E0E0]">
<h2 className="text-lg font-semibold">Preview</h2>
<AgentPreviewArea />
<div className="col-span-3 flex flex-col gap-2 max-[1179px]:h-auto max-[1179px]:px-0 max-[1179px]:py-0 min-[1180px]:h-full min-[1180px]:py-2 dark:text-[#E0E0E0]">
<h2 className="text-lg font-semibold">
{t('agents.form.sections.preview')}
</h2>
<div className="flex-1 max-[1179px]:overflow-visible min-[1180px]:min-h-0 min-[1180px]:overflow-hidden">
<AgentPreviewArea />
</div>
</div>
</div>
<ConfirmationModal
message="Are you sure you want to delete this agent?"
message={t('agents.deleteConfirmation')}
modalState={deleteConfirmation}
setModalState={setDeleteConfirmation}
submitLabel="Delete"
submitLabel={t('agents.form.buttons.delete')}
handleSubmit={() => {
handleDelete(agent.id || '');
setDeleteConfirmation('INACTIVE');
}}
cancelLabel="Cancel"
cancelLabel={t('agents.form.buttons.cancel')}
variant="danger"
/>
<AgentDetailsModal
@@ -932,18 +1252,19 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
}
function AgentPreviewArea() {
const { t } = useTranslation();
const selectedAgent = useSelector(selectSelectedAgent);
return (
<div className="dark:bg-raisin-black h-full w-full rounded-[30px] border border-[#F6F6F6] bg-white max-[1180px]:h-192 dark:border-[#7E7E7E]">
<div className="dark:bg-raisin-black w-full rounded-[30px] border border-[#F6F6F6] bg-white max-[1179px]:h-[600px] min-[1180px]:h-full dark:border-[#7E7E7E]">
{selectedAgent?.status === 'published' ? (
<div className="flex h-full w-full flex-col justify-end overflow-auto rounded-[30px]">
<div className="flex h-full w-full flex-col overflow-hidden rounded-[30px]">
<AgentPreview />
</div>
) : (
<div className="flex h-full w-full flex-col items-center justify-center gap-2">
<span className="block h-12 w-12 bg-[url('/src/assets/science-spark.svg')] bg-contain bg-center bg-no-repeat transition-all dark:bg-[url('/src/assets/science-spark-dark.svg')]" />{' '}
<p className="dark:text-gray-4000 text-xs text-[#18181B]">
Published agents can be previewed here
{t('agents.form.preview.publishedPreview')}
</p>
</div>
)}

View File

@@ -144,7 +144,7 @@ export default function SharedAgent() {
className="mx-auto mb-6 h-32 w-32"
/>
<p className="dark:text-gray-4000 text-center text-lg text-[#71717A]">
No agent found. Please ensure the agent is shared.
{t('agents.shared.notFound')}
</p>
</div>
</div>
@@ -177,13 +177,15 @@ export default function SharedAgent() {
/>
</div>
<div className="flex w-[95%] max-w-[1500px] flex-col items-center pb-2 md:w-9/12 lg:w-8/12 xl:w-8/12 2xl:w-6/12">
<MessageInput
onSubmit={(text) => handleQuestionSubmission(text)}
loading={status === 'loading'}
showSourceButton={sharedAgent ? false : true}
showToolButton={sharedAgent ? false : true}
autoFocus={false}
/>
<div className="w-full px-2">
<MessageInput
onSubmit={(text) => handleQuestionSubmission(text)}
loading={status === 'loading'}
showSourceButton={sharedAgent ? false : true}
showToolButton={sharedAgent ? false : true}
autoFocus={false}
/>
</div>
<p className="text-gray-4000 dark:text-sonic-silver hidden w-screen self-center bg-transparent py-2 text-center text-xs md:inline md:w-full">
{t('tagline')}
</p>

View File

@@ -2,6 +2,12 @@ import AgentImage from '../components/AgentImage';
import { Agent } from './types';
export default function SharedAgentCard({ agent }: { agent: Agent }) {
// Check if shared metadata exists and has properties (type is 'any' so we validate it's a non-empty object)
const hasSharedMetadata =
agent.shared_metadata &&
typeof agent.shared_metadata === 'object' &&
agent.shared_metadata !== null &&
Object.keys(agent.shared_metadata).length > 0;
return (
<div className="border-dark-gray dark:border-grey flex w-full max-w-[720px] flex-col rounded-3xl border p-6 shadow-xs sm:w-fit sm:min-w-[480px]">
<div className="flex items-center gap-3">
@@ -20,7 +26,7 @@ export default function SharedAgentCard({ agent }: { agent: Agent }) {
</p>
</div>
</div>
{agent.shared_metadata && (
{hasSharedMetadata && (
<div className="mt-4 flex items-center gap-8">
{agent.shared_metadata?.shared_by && (
<p className="text-eerie-black text-xs font-light sm:text-sm dark:text-[#E0E0E0]">

View File

@@ -1,19 +1,20 @@
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
import {
handleFetchAnswer,
handleFetchAnswerSteaming,
} from '../conversation/conversationHandlers';
import {
Answer,
ConversationState,
Query,
Status,
} from '../conversation/conversationModels';
import {
handleFetchAnswer,
handleFetchAnswerSteaming,
} from '../conversation/conversationHandlers';
import {
selectCompletedAttachments,
clearAttachments,
} from '../upload/uploadSlice';
import store from '../store';
import {
clearAttachments,
selectCompletedAttachments,
} from '../upload/uploadSlice';
const initialState: ConversationState = {
queries: [],
@@ -51,12 +52,16 @@ export const fetchPreviewAnswer = createAsyncThunk<
}
if (state.preference) {
const modelId =
state.preference.selectedAgent?.default_model_id ||
state.preference.selectedModel?.id;
if (API_STREAMING) {
await handleFetchAnswerSteaming(
question,
signal,
state.preference.token,
state.preference.selectedDocs!,
state.preference.selectedDocs,
null, // No conversation ID for previews
state.preference.prompt.id,
state.preference.chunks,
@@ -119,22 +124,23 @@ export const fetchPreviewAnswer = createAsyncThunk<
indx,
state.preference.selectedAgent?.id,
attachmentIds,
false, // Don't save preview conversations
false,
modelId,
);
} else {
// Non-streaming implementation
const answer = await handleFetchAnswer(
question,
signal,
state.preference.token,
state.preference.selectedDocs!,
null, // No conversation ID for previews
state.preference.selectedDocs,
null,
state.preference.prompt.id,
state.preference.chunks,
state.preference.token_limit,
state.preference.selectedAgent?.id,
attachmentIds,
false, // Don't save preview conversations
false,
modelId,
);
if (answer) {

View File

@@ -0,0 +1,42 @@
import userService from '../api/services/userService';
import {
selectAgents,
selectTemplateAgents,
selectSharedAgents,
setAgents,
setTemplateAgents,
setSharedAgents,
} from '../preferences/preferenceSlice';
export const agentSectionsConfig = [
{
id: 'template',
title: 'By DocsGPT',
description: 'Agents provided by DocsGPT',
showNewAgentButton: false,
emptyStateDescription: 'No template agents found.',
fetchAgents: (token: string | null) => userService.getTemplateAgents(token),
selectData: selectTemplateAgents,
updateAction: setTemplateAgents,
},
{
id: 'user',
title: 'By me',
description: 'Agents created or published by you',
showNewAgentButton: true,
emptyStateDescription: 'You dont have any created agents yet.',
fetchAgents: (token: string | null) => userService.getAgents(token),
selectData: selectAgents,
updateAction: setAgents,
},
{
id: 'shared',
title: 'Shared with me',
description: 'Agents imported by using a public link',
showNewAgentButton: false,
emptyStateDescription: 'No shared agents found.',
fetchAgents: (token: string | null) => userService.getSharedAgents(token),
selectData: selectSharedAgents,
updateAction: setSharedAgents,
},
];

View File

@@ -1,37 +1,9 @@
import { SyntheticEvent, useEffect, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { Route, Routes, useNavigate } from 'react-router-dom';
import { Route, Routes } from 'react-router-dom';
import userService from '../api/services/userService';
import Edit from '../assets/edit.svg';
import Link from '../assets/link-gray.svg';
import Monitoring from '../assets/monitoring.svg';
import Pin from '../assets/pin.svg';
import Trash from '../assets/red-trash.svg';
import AgentImage from '../components/AgentImage';
import ThreeDots from '../assets/three-dots.svg';
import UnPin from '../assets/unpin.svg';
import ContextMenu, { MenuOption } from '../components/ContextMenu';
import Spinner from '../components/Spinner';
import {
setConversation,
updateConversationId,
} from '../conversation/conversationSlice';
import ConfirmationModal from '../modals/ConfirmationModal';
import { ActiveState } from '../models/misc';
import {
selectAgents,
selectSelectedAgent,
selectSharedAgents,
selectToken,
setAgents,
setSelectedAgent,
setSharedAgents,
} from '../preferences/preferenceSlice';
import AgentLogs from './AgentLogs';
import AgentsList from './AgentsList';
import NewAgent from './NewAgent';
import SharedAgent from './SharedAgent';
import { Agent } from './types';
export default function Agents() {
return (
@@ -44,427 +16,3 @@ export default function Agents() {
</Routes>
);
}
const sectionConfig = {
user: {
title: 'By me',
description: 'Agents created or published by you',
showNewAgentButton: true,
emptyStateDescription: 'You dont have any created agents yet',
},
shared: {
title: 'Shared with me',
description: 'Agents imported by using a public link',
showNewAgentButton: false,
emptyStateDescription: 'No shared agents found',
},
};
function AgentsList() {
const dispatch = useDispatch();
const token = useSelector(selectToken);
const agents = useSelector(selectAgents);
const sharedAgents = useSelector(selectSharedAgents);
const selectedAgent = useSelector(selectSelectedAgent);
const [loadingUserAgents, setLoadingUserAgents] = useState<boolean>(true);
const [loadingSharedAgents, setLoadingSharedAgents] = useState<boolean>(true);
const getAgents = async () => {
try {
setLoadingUserAgents(true);
const response = await userService.getAgents(token);
if (!response.ok) throw new Error('Failed to fetch agents');
const data = await response.json();
dispatch(setAgents(data));
setLoadingUserAgents(false);
} catch (error) {
console.error('Error:', error);
setLoadingUserAgents(false);
}
};
const getSharedAgents = async () => {
try {
setLoadingSharedAgents(true);
const response = await userService.getSharedAgents(token);
if (!response.ok) throw new Error('Failed to fetch shared agents');
const data = await response.json();
dispatch(setSharedAgents(data));
setLoadingSharedAgents(false);
} catch (error) {
console.error('Error:', error);
setLoadingSharedAgents(false);
}
};
useEffect(() => {
getAgents();
getSharedAgents();
dispatch(setConversation([]));
dispatch(
updateConversationId({
query: { conversationId: null },
}),
);
if (selectedAgent) dispatch(setSelectedAgent(null));
}, [token]);
return (
<div className="p-4 md:p-12">
<h1 className="text-eerie-black mb-0 text-[32px] font-bold lg:text-[40px] dark:text-[#E0E0E0]">
Agents
</h1>
<p className="dark:text-gray-4000 mt-5 text-[15px] text-[#71717A]">
Discover and create custom versions of DocsGPT that combine
instructions, extra knowledge, and any combination of skills
</p>
{/* Premade agents section */}
{/* <div className="mt-6">
<h2 className="text-[18px] font-semibold text-[#18181B] dark:text-[#E0E0E0]">
Premade by DocsGPT
</h2>
<div className="mt-4 flex w-full flex-wrap gap-4">
{Array.from({ length: 5 }, (_, index) => (
<div
key={index}
className="relative flex h-44 w-48 flex-col justify-between rounded-[1.2rem] bg-[#F6F6F6] px-6 py-5 dark:bg-[#383838]"
>
<button onClick={() => {}} className="absolute right-4 top-4">
<img
src={Copy}
alt={'use-agent'}
className="h-[19px] w-[19px]"
/>
</button>
<div className="w-full">
<div className="flex w-full items-center px-1">
<AgentImage className="h-7 w-7 rounded-full" />
</div>
<div className="mt-2">
<p
title={''}
className="truncate px-1 text-[13px] font-semibold capitalize leading-relaxed text-raisin-black-light dark:text-bright-gray"
>
{}
</p>
<p className="mt-1 h-20 overflow-auto px-1 text-[12px] leading-relaxed text-old-silver dark:text-sonic-silver-light">
{}
</p>
</div>
</div>
<div className="absolute bottom-4 right-4"></div>
</div>
))}
</div>
</div> */}
<AgentSection
agents={agents ?? []}
updateAgents={(updatedAgents) => {
dispatch(setAgents(updatedAgents));
}}
loading={loadingUserAgents}
section="user"
/>
<AgentSection
agents={sharedAgents ?? []}
updateAgents={(updatedAgents) => {
dispatch(setSharedAgents(updatedAgents));
}}
loading={loadingSharedAgents}
section="shared"
/>
</div>
);
}
function AgentSection({
agents,
updateAgents,
loading,
section,
}: {
agents: Agent[];
updateAgents?: (agents: Agent[]) => void;
loading: boolean;
section: keyof typeof sectionConfig;
}) {
const navigate = useNavigate();
return (
<div className="mt-8 flex flex-col gap-4">
<div className="flex w-full items-center justify-between">
<div className="flex flex-col gap-2">
<h2 className="text-[18px] font-semibold text-[#18181B] dark:text-[#E0E0E0]">
{sectionConfig[section].title}
</h2>
<p className="text-[13px] text-[#71717A]">
{sectionConfig[section].description}
</p>
</div>
{sectionConfig[section].showNewAgentButton && (
<button
className="bg-purple-30 hover:bg-violets-are-blue rounded-full px-4 py-2 text-sm text-white"
onClick={() => navigate('/agents/new')}
>
New Agent
</button>
)}
</div>
<div>
{loading ? (
<div className="flex h-72 w-full items-center justify-center">
<Spinner />
</div>
) : agents && agents.length > 0 ? (
<div className="grid grid-cols-1 gap-4 sm:flex sm:flex-wrap">
{agents.map((agent, idx) => (
<AgentCard
key={agent.id}
agent={agent}
agents={agents}
updateAgents={updateAgents}
section={section}
/>
))}
</div>
) : (
<div className="flex h-72 w-full flex-col items-center justify-center gap-3 text-base text-[#18181B] dark:text-[#E0E0E0]">
<p>{sectionConfig[section].emptyStateDescription}</p>
{sectionConfig[section].showNewAgentButton && (
<button
className="bg-purple-30 hover:bg-violets-are-blue ml-2 rounded-full px-4 py-2 text-sm text-white"
onClick={() => navigate('/agents/new')}
>
New Agent
</button>
)}
</div>
)}
</div>
</div>
);
}
function AgentCard({
agent,
agents,
updateAgents,
section,
}: {
agent: Agent;
agents: Agent[];
updateAgents?: (agents: Agent[]) => void;
section: keyof typeof sectionConfig;
}) {
const navigate = useNavigate();
const dispatch = useDispatch();
const token = useSelector(selectToken);
const [isMenuOpen, setIsMenuOpen] = useState<boolean>(false);
const [deleteConfirmation, setDeleteConfirmation] =
useState<ActiveState>('INACTIVE');
const menuRef = useRef<HTMLDivElement>(null);
const togglePin = async () => {
try {
const response = await userService.togglePinAgent(agent.id ?? '', token);
if (!response.ok) throw new Error('Failed to pin agent');
const updatedAgents = agents.map((prevAgent) => {
if (prevAgent.id === agent.id)
return { ...prevAgent, pinned: !prevAgent.pinned };
return prevAgent;
});
updateAgents?.(updatedAgents);
} catch (error) {
console.error('Error:', error);
}
};
const handleHideSharedAgent = async () => {
try {
const response = await userService.removeSharedAgent(
agent.id ?? '',
token,
);
if (!response.ok) throw new Error('Failed to hide shared agent');
const updatedAgents = agents.filter(
(prevAgent) => prevAgent.id !== agent.id,
);
updateAgents?.(updatedAgents);
} catch (error) {
console.error('Error:', error);
}
};
const menuOptionsConfig: Record<string, MenuOption[]> = {
user: [
{
icon: Monitoring,
label: 'Logs',
onClick: (e: SyntheticEvent) => {
e.stopPropagation();
navigate(`/agents/logs/${agent.id}`);
},
variant: 'primary',
iconWidth: 14,
iconHeight: 14,
},
{
icon: Edit,
label: 'Edit',
onClick: (e: SyntheticEvent) => {
e.stopPropagation();
navigate(`/agents/edit/${agent.id}`);
},
variant: 'primary',
iconWidth: 14,
iconHeight: 14,
},
...(agent.status === 'published'
? [
{
icon: agent.pinned ? UnPin : Pin,
label: agent.pinned ? 'Unpin' : 'Pin agent',
onClick: (e: SyntheticEvent) => {
e.stopPropagation();
togglePin();
},
variant: 'primary' as const,
iconWidth: 18,
iconHeight: 18,
},
]
: []),
{
icon: Trash,
label: 'Delete',
onClick: (e: SyntheticEvent) => {
e.stopPropagation();
setDeleteConfirmation('ACTIVE');
},
variant: 'danger',
iconWidth: 13,
iconHeight: 13,
},
],
shared: [
{
icon: Link,
label: 'Open',
onClick: (e: SyntheticEvent) => {
e.stopPropagation();
navigate(`/agents/shared/${agent.shared_token}`);
},
variant: 'primary',
iconWidth: 12,
iconHeight: 12,
},
{
icon: agent.pinned ? UnPin : Pin,
label: agent.pinned ? 'Unpin' : 'Pin agent',
onClick: (e: SyntheticEvent) => {
e.stopPropagation();
togglePin();
},
variant: 'primary',
iconWidth: 18,
iconHeight: 18,
},
{
icon: Trash,
label: 'Remove',
onClick: (e: SyntheticEvent) => {
e.stopPropagation();
handleHideSharedAgent();
},
variant: 'danger',
iconWidth: 13,
iconHeight: 13,
},
],
};
const menuOptions = menuOptionsConfig[section] || [];
const handleClick = () => {
if (section === 'user') {
if (agent.status === 'published') {
dispatch(setSelectedAgent(agent));
navigate(`/`);
}
}
if (section === 'shared') {
navigate(`/agents/shared/${agent.shared_token}`);
}
};
const handleDelete = async (agentId: string) => {
const response = await userService.deleteAgent(agentId, token);
if (!response.ok) throw new Error('Failed to delete agent');
const data = await response.json();
dispatch(setAgents(agents.filter((prevAgent) => prevAgent.id !== data.id)));
};
return (
<div
className={`relative flex h-44 w-full flex-col justify-between rounded-[1.2rem] bg-[#F6F6F6] px-6 py-5 hover:bg-[#ECECEC] md:w-48 dark:bg-[#383838] dark:hover:bg-[#383838]/80 ${agent.status === 'published' && 'cursor-pointer'}`}
onClick={(e) => {
e.stopPropagation();
handleClick();
}}
>
<div
ref={menuRef}
onClick={(e) => {
e.stopPropagation();
setIsMenuOpen(true);
}}
className="absolute top-4 right-4 z-10 cursor-pointer"
>
<img src={ThreeDots} alt={'use-agent'} className="h-[19px] w-[19px]" />
<ContextMenu
isOpen={isMenuOpen}
setIsOpen={setIsMenuOpen}
options={menuOptions}
anchorRef={menuRef}
position="bottom-right"
offset={{ x: 0, y: 0 }}
/>
</div>
<div className="w-full">
<div className="flex w-full items-center gap-1 px-1">
<AgentImage
src={agent.image}
alt={`${agent.name}`}
className="h-7 w-7 rounded-full object-contain"
/>
{agent.status === 'draft' && (
<p className="text-xs text-black opacity-50 dark:text-[#E0E0E0]">{`(Draft)`}</p>
)}
</div>
<div className="mt-2">
<p
title={agent.name}
className="truncate px-1 text-[13px] leading-relaxed font-semibold text-[#020617] capitalize dark:text-[#E0E0E0]"
>
{agent.name}
</p>
<p className="dark:text-sonic-silver-light mt-1 h-20 overflow-auto px-1 text-[12px] leading-relaxed text-[#64748B]">
{agent.description}
</p>
</div>
</div>
<ConfirmationModal
message="Are you sure you want to delete this agent?"
modalState={deleteConfirmation}
setModalState={setDeleteConfirmation}
submitLabel="Delete"
handleSubmit={() => {
handleDelete(agent.id || '');
setDeleteConfirmation('INACTIVE');
}}
cancelLabel="Cancel"
variant="danger"
/>
</div>
);
}

View File

@@ -28,4 +28,10 @@ export type Agent = {
updated_at?: string;
last_used_at?: string;
json_schema?: object;
limited_token_mode?: boolean;
token_limit?: number;
limited_request_mode?: boolean;
request_limit?: number;
models?: string[];
default_model_id?: string;
};

View File

@@ -2,8 +2,8 @@ const endpoints = {
USER: {
CONFIG: '/api/config',
NEW_TOKEN: '/api/generate_token',
MODELS: '/api/models',
DOCS: '/api/sources',
DOCS_CHECK: '/api/docs_check',
DOCS_PAGINATED: '/api/sources/paginated',
API_KEYS: '/api/get_api_keys',
CREATE_API_KEY: '/api/create_api_key',
@@ -19,6 +19,8 @@ const endpoints = {
SHARED_AGENTS: '/api/shared_agents',
SHARE_AGENT: `/api/share_agent`,
REMOVE_SHARED_AGENT: (id: string) => `/api/remove_shared_agent?id=${id}`,
TEMPLATE_AGENTS: '/api/template_agents',
ADOPT_AGENT: (id: string) => `/api/adopt_agent?id=${id}`,
AGENT_WEBHOOK: (id: string) => `/api/agent_webhook?id=${id}`,
PROMPTS: '/api/get_prompts',
CREATE_PROMPT: '/api/create_prompt',

View File

@@ -0,0 +1,25 @@
import apiClient from '../client';
import endpoints from '../endpoints';
import type { AvailableModel, Model } from '../../models/types';
const modelService = {
getModels: (token: string | null): Promise<Response> =>
apiClient.get(endpoints.USER.MODELS, token, {}),
transformModels: (models: AvailableModel[]): Model[] =>
models.map((model) => ({
id: model.id,
value: model.id,
provider: model.provider,
display_name: model.display_name,
description: model.description,
context_window: model.context_window,
supported_attachment_types: model.supported_attachment_types,
supports_tools: model.supports_tools,
supports_structured_output: model.supports_structured_output,
supports_streaming: model.supports_streaming,
})),
};
export default modelService;

View File

@@ -10,8 +10,6 @@ const userService = {
apiClient.get(`${endpoints.USER.DOCS}`, token),
getDocsWithPagination: (query: string, token: string | null): Promise<any> =>
apiClient.get(`${endpoints.USER.DOCS_PAGINATED}?${query}`, token),
checkDocs: (data: any, token: string | null): Promise<any> =>
apiClient.post(endpoints.USER.DOCS_CHECK, data, token),
getAPIKeys: (token: string | null): Promise<any> =>
apiClient.get(endpoints.USER.API_KEYS, token),
createAPIKey: (data: any, token: string | null): Promise<any> =>
@@ -44,6 +42,10 @@ const userService = {
apiClient.put(endpoints.USER.SHARE_AGENT, data, token),
removeSharedAgent: (id: string, token: string | null): Promise<any> =>
apiClient.delete(endpoints.USER.REMOVE_SHARED_AGENT(id), token),
getTemplateAgents: (token: string | null): Promise<any> =>
apiClient.get(endpoints.USER.TEMPLATE_AGENTS, token),
adoptAgent: (id: string, token: string | null): Promise<any> =>
apiClient.post(endpoints.USER.ADOPT_AGENT(id), {}, token),
getAgentWebhook: (id: string, token: string | null): Promise<any> =>
apiClient.get(endpoints.USER.AGENT_WEBHOOK(id), token),
getPrompts: (token: string | null): Promise<any> =>

View File

@@ -0,0 +1,3 @@
<svg width="14" height="13" viewBox="0 0 14 13" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.0303 7.03033C13.3232 6.73744 13.3232 6.26256 13.0303 5.96967L8.25736 1.1967C7.96447 0.903806 7.48959 0.903806 7.1967 1.1967C6.90381 1.48959 6.90381 1.96447 7.1967 2.25736L11.4393 6.5L7.1967 10.7426C6.90381 11.0355 6.90381 11.5104 7.1967 11.8033C7.48959 12.0962 7.96447 12.0962 8.25736 11.8033L13.0303 7.03033ZM0.5 6.5V7.25H12.5V6.5V5.75H0.5V6.5Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 478 B

View File

@@ -1,3 +0,0 @@
<svg width="8" height="12" viewBox="0 0 8 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.41 10.59L2.83 6L7.41 1.41L6 0L0 6L6 12L7.41 10.59Z" fill="black" fill-opacity="0.54"/>
</svg>

Before

Width:  |  Height:  |  Size: 200 B

View File

@@ -0,0 +1,3 @@
<svg width="12" height="14" viewBox="0 0 12 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.2857 14H2.57143C1.15179 14 0 12.8242 0 11.375V2.625C0 1.17578 1.15179 0 2.57143 0H10.7143C11.4241 0 12 0.587891 12 1.3125V9.1875C12 9.75898 11.6411 10.2457 11.1429 10.4262V12.25C11.617 12.25 12 12.641 12 13.125C12 13.609 11.617 14 11.1429 14H10.2857ZM2.57143 10.5C2.09732 10.5 1.71429 10.891 1.71429 11.375C1.71429 11.859 2.09732 12.25 2.57143 12.25H9.42857V10.5H2.57143ZM3.42857 4.15625C3.42857 4.51992 3.71518 4.8125 4.07143 4.8125H8.78571C9.14196 4.8125 9.42857 4.51992 9.42857 4.15625C9.42857 3.79258 9.14196 3.5 8.78571 3.5H4.07143C3.71518 3.5 3.42857 3.79258 3.42857 4.15625ZM4.07143 6.125C3.71518 6.125 3.42857 6.41758 3.42857 6.78125C3.42857 7.14492 3.71518 7.4375 4.07143 7.4375H8.78571C9.14196 7.4375 9.42857 7.14492 9.42857 6.78125C9.42857 6.41758 9.14196 6.125 8.78571 6.125H4.07143Z" fill="#6A4DF4"/>
</svg>

After

Width:  |  Height:  |  Size: 930 B

Some files were not shown because too many files have changed in this diff Show More