├── .eslintignore ├── .eslintrc.cjs ├── .github ├── FUNDING.yml └── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── custom.md │ └── feature_request.md ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc ├── Dockerfile ├── Dockerfile.dev ├── LICENSE ├── Makefile ├── OLLAMA_GUIDE.md ├── README.md ├── README.md.old ├── backend ├── .air.toml ├── Dockerfile ├── Dockerfile.dev ├── agentChain.go ├── apiServer.go ├── e2e │ ├── e2e_suite_test.go │ └── simple_question_test.go ├── go.mod ├── go.sum ├── llm_tools │ ├── simple_websearch.go │ ├── tool_search_vector_db.go │ └── tool_webscrape.go ├── lschains │ ├── custom_structured_parser.go │ ├── format_sources_chain.go │ ├── ollama_functioncall.go │ ├── source_chain_example.go │ └── test_chain.go ├── main.go └── utils │ ├── customHandler.go │ ├── helper.go │ ├── llm_backends.go │ ├── load_localfiles.go │ ├── prompts.go │ ├── types.go │ └── vector_db_handler.go ├── custom-server.js ├── docker-compose.dev.yaml ├── docker-compose.yaml ├── env-example ├── go.work ├── go.work.sum ├── infra.drawio ├── metrics ├── Dockerfile ├── go.mod ├── main.go └── tmp │ ├── build-errors.log │ └── main ├── nginx.conf ├── package-lock.json ├── package.json ├── postcss.config.js ├── searxng ├── limiter.toml ├── settings.yml └── uwsgi.ini ├── src ├── app.css ├── app.d.ts ├── app.html ├── lib │ ├── bottom_bar.svelte │ ├── chatHistory.svelte │ ├── chatList.svelte │ ├── chatListItemElem.svelte │ ├── chat_button.svelte │ ├── clickOutside.js │ ├── index.ts │ ├── loading_message.svelte │ ├── log_item.svelte │ ├── log_node.svelte │ ├── model_switch_window.svelte │ ├── new_chat_button.svelte │ ├── settings_field.svelte │ ├── settings_window.svelte │ ├── show_logs_button.svelte │ ├── sidebar.svelte │ ├── sidebar_history_toggle.svelte │ ├── sidebar_sources_toggle.svelte │ ├── sources.svelte │ ├── toggle_darkmode_button.svelte │ ├── toggle_model_switch.svelte │ ├── toggle_settings_button.svelte │ ├── toggle_sidebar_button.svelte │ ├── top_button.svelte │ ├── topbar_button.svelte │ └── types │ │ └── types.ts └── routes │ ├── +layout.svelte │ ├── +layout.ts │ ├── +page.server.ts │ ├── +page.svelte │ ├── +page.svelte.old │ └── chat │ └── [slug] │ ├── +page.svelte │ ├── handle_darkmode.ts │ └── load_functions.ts ├── static ├── favicon.png └── favicon.svg ├── svelte.config.js ├── tailwind.config.js ├── tsconfig.json └── vite.config.ts /.eslintignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | 10 | # Ignore files for PNPM, NPM and YARN 11 | pnpm-lock.yaml 12 | package-lock.json 13 | yarn.lock 14 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /** @type { import("eslint").Linter.Config } */ 2 | module.exports = { 3 | root: true, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'plugin:svelte/recommended', 8 | 'prettier' 9 | ], 10 | parser: '@typescript-eslint/parser', 11 | plugins: ['@typescript-eslint'], 12 | parserOptions: { 13 | sourceType: 'module', 14 | ecmaVersion: 2020, 15 | extraFileExtensions: ['.svelte'] 16 | }, 17 | env: { 18 | browser: true, 19 | es2017: true, 20 | node: true 21 | }, 22 | overrides: [ 23 | { 24 | files: ['*.svelte'], 25 | parser: 'svelte-eslint-parser', 26 | parserOptions: { 27 | parser: '@typescript-eslint/parser' 28 | } 29 | } 30 | ] 31 | }; 32 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: nilsherzig # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: nilsherzig # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: # Replace with a single Buy Me a Coffee username 14 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Additional context** 27 | Add any other context about the problem here. 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/custom.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Custom issue template 3 | about: Describe this issue template's purpose here. 4 | title: '' 5 | labels: question 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | vite.config.js.timestamp-* 10 | vite.config.ts.timestamp-* 11 | backend/tmp 12 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore files for PNPM, NPM and YARN 2 | pnpm-lock.yaml 3 | package-lock.json 4 | yarn.lock 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": true, 3 | "singleQuote": true, 4 | "trailingComma": "none", 5 | "printWidth": 100, 6 | "plugins": ["prettier-plugin-svelte"], 7 | "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] 8 | } 9 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:alpine as build 2 | ARG PUBLIC_VERSION="0" 3 | ENV PUBLIC_VERSION=${PUBLIC_VERSION} 4 | 5 | ADD . /app 6 | WORKDIR /app 7 | 8 | RUN npm install 9 | RUN npm run build 10 | 11 | FROM nginx:stable-alpine 12 | COPY nginx.conf /etc/nginx/conf.d/default.conf 13 | COPY --from=build /app/build /usr/share/nginx/html 14 | -------------------------------------------------------------------------------- /Dockerfile.dev: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine3.19 2 | WORKDIR /app 3 | EXPOSE 5173 4 | 5 | CMD ["sh", "-c", "npm install && npm run dev -- --host"] 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | #TODO use this as a version 2 | GIT_HASH := $(shell git rev-parse --short HEAD) 3 | 4 | LATEST_TAG := $(shell git describe --tags --abbrev=0) 5 | CURRENT_TIMESTAMP := $(shell date +%s) 6 | 7 | # used for local testing, so i can save the platform build time 8 | PHONY: build-containers 9 | build-containers: 10 | (cd ./metrics/ && docker buildx build --build-arg="VERSION=$(CURRENT_TIMESTAMP)" . -t nilsherzig/lsm:latest --load) 11 | docker buildx build --build-arg="PUBLIC_VERSION=$(CURRENT_TIMESTAMP)" . -t nilsherzig/llocalsearch-frontend:latest --load 12 | (cd ./backend/ && docker buildx build . -t nilsherzig/llocalsearch-backend:latest --load) 13 | 14 | # containers which will be published 15 | PHONY: build-containers-multi 16 | build-containers-multi: 17 | (cd ./metrics/ && docker buildx build --build-arg="VERSION=$(CURRENT_TIMESTAMP)" . -t nilsherzig/lsm:latest --push --platform linux/amd64,linux/arm64) 18 | docker buildx build --build-arg="PUBLIC_VERSION=$(CURRENT_TIMESTAMP)" . -t nilsherzig/llocalsearch-frontend:latest --push --platform linux/amd64,linux/arm64 19 | (cd ./backend/ && docker buildx build . -t nilsherzig/llocalsearch-backend:latest --push --platform linux/amd64,linux/arm64) 20 | 21 | PHONY: new-release 22 | new-release: build-containers-multi 23 | @echo "New release pushed to Docker Hub" 24 | 25 | PHONY: e2e-backend 26 | e2e-backend: 27 | (cd ./backend && ginkgo -v -r ./...) 28 | 29 | # dev run commands 30 | PHONY: build-dev 31 | build-dev: 32 | docker-compose -f ./docker-compose.dev.yaml build 33 | 34 | PHONY: dev 35 | dev: build-dev 36 | docker-compose -f ./docker-compose.dev.yaml up $(ARGS) 37 | 38 | PHONY: dev-bg 39 | dev-bg: build-dev 40 | docker-compose -f ./docker-compose.dev.yaml up -d 41 | -------------------------------------------------------------------------------- /OLLAMA_GUIDE.md: -------------------------------------------------------------------------------- 1 | # Instructions on how to get LLocalSearch working with your Ollama instance 2 | 3 | 4 | - [Instructions on how to get LLocalSearch working with your Ollama instance](#instructions-on-how-to-get-llocalsearch-working-with-your-ollama-instance) 5 | - [You're running Ollama on your host machine (without docker)](#youre-running-ollama-on-your-host-machine-without-docker) 6 | - [You're using Linux or macOS](#youre-using-linux-or-macos) 7 | - [You're using Windows](#youre-using-windows) 8 | - [You're running Ollama in a docker container on the same machine as LLocalSearch](#youre-running-ollama-in-a-docker-container-on-the-same-machine-as-llocalsearch) 9 | - [You're running Ollama on a Server or different machine](#youre-running-ollama-on-a-server-or-different-machine) 10 | 11 | 12 | ## You're running Ollama on your host machine (without docker) 13 | 14 | ### You're using Linux or macOS 15 | 16 | 1. Make sure Ollama is listening on all interfaces (`0.0.0.0`, or at least the docker network). 17 | 2. Add the following to the `.env` file (create one if it doesn't exist) in the root of the project: 18 | 19 | ```yaml 20 | OLLAMA_HOST=host.docker.internal:11434 21 | ``` 22 | 23 | > [!WARNING] 24 | > Some linux users reported that this solution requires docker desktop to be installed. Please report back if that's the case for you. I don't have this issue on NixOS or my Ubuntu 22.04 test box. 25 | 26 | ### You're using Windows 27 | 28 | Try the above and tell me if it worked, I will update these docs. 29 | 30 | ## You're running Ollama in a docker container on the same machine as LLocalSearch 31 | 32 | 1. Make sure your exposing Ollama on port 11434. 33 | 2. Add the following to the `.env` file (create one if it doesn't exist) in the root of the project: 34 | 35 | ```yaml 36 | OLLAMA_HOST=host.docker.internal:11434 37 | ``` 38 | 39 | ## You're running Ollama on a Server or different machine 40 | 41 | 1. Make sure Ollama is reachable from the container. 42 | 2. Add the following to the `.env` file (create one if it doesn't exist) in the root of the project: 43 | 44 | ```yaml 45 | OLLAMA_HOST=ollama-server-ip:11434 46 | ``` 47 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > [!WARNING] 2 | > This version has not been under development for over a year. Im working on a rewrite / relaunch within a private beta - to gather feedback without wasting everyones time by publishing incomplete software. Please contact me if youre interested to join. 3 | 4 | # LLocalSearch 5 | 6 | ## What it is and what it does 7 | 8 | LLocalSearch is a wrapper around locally running `Large Language Models` (like ChatGTP, but a lot smaller and less "smart") which allows them to choose from a set of tools. These tools allow them to search the internet for current information about your question. This process is recursive, which means, that the running LLM can freely choose to use tools (even multiple times) based on the information its getting from you and other tool calls. 9 | 10 | [demo.webm](https://github.com/nilsherzig/LLocalSearch/assets/72463901/e13e2531-05a8-40af-8551-965ed9d24eb4) 11 | 12 | ### Why would I want to use this and not something from `xy`? 13 | 14 | The long term plan, which OpenAI is [selling](https://www.adweek.com/media/openai-preferred-publisher-program-deck/) to big media houses: 15 | 16 | > Additionally, members of the program receive priority placement and “richer brand expression” in chat conversations, and their content benefits from more prominent link treatments. 17 | 18 | If you dislike the idea of getting manipulated by the highest bidder, you might want to try some less discriminatory alternatives, like this project. 19 | 20 | ### Features 21 | 22 | - 🕵‍♀ Completely local (no need for API keys) and thus a lot more privacy respecting 23 | - 💸 Runs on "low end" hardware (the demo video uses a 300€ GPU) 24 | - 🤓 Live logs and links in the answer allow you do get a better understanding about what the agent is doing and what information the answer is based on. Allowing for a great starting point to dive deeper into your research. 25 | - 🤔 Supports follow up questions 26 | - 📱 Mobile friendly design 27 | - 🌓 Dark and light mode 28 | 29 | 30 | ## Road-map 31 | 32 | ### I'm currently working on 👷 33 | 34 | #### Support for LLama3 🦙 35 | 36 | The langchain library im using does not respect the LLama3 stop words, which results in LLama3 starting to hallucinate at the end of a turn. I have a working patch (checkout the experiments branch), but since im unsure if my way is the right way to solve this, im still waiting for a response from the [langchaingo](https://github.com/tmc/langchaingo) team. 37 | 38 | #### Interface overhaul 🌟 39 | 40 | An Interface overhaul, allowing for more flexible panels and more efficient use of space. 41 | Inspired by the current layout of [Obsidian](https://obsidian.md) 42 | 43 | #### Support for chat histories / recent conversations 🕵‍♀ 44 | 45 | Still needs a lot of work, like refactoring a lot of the internal data structures to allow for more better and more flexible ways to expand the functionality in the future without having to rewrite the whole data transmission and interface part again. 46 | 47 | 48 | ### Planned (near future) 49 | 50 | #### User Accounts 🙆 51 | 52 | Groundwork for private information inside the rag chain, like uploading your own documents, or connecting LLocalSearch to services like Google Drive, or Confluence. 53 | 54 | #### Long term memory 🧠 55 | 56 | Not sure if there is a right way to implement this, but provide the main agent chain with information about the user, like preferences and having an extra Vector DB Namespace per user for persistent information. 57 | 58 | ## Install Guide 59 | 60 | ### Docker 🐳 61 | 62 | 1. Clone the GitHub Repository 63 | 64 | ```bash 65 | git@github.com:nilsherzig/LLocalSearch.git 66 | cd LLocalSearch 67 | ``` 68 | 69 | 2. Create and edit an `.env` file, if you need to change some of the default settings. This is typically only needed if you have Ollama running on a different device or if you want to build a more complex setup (for more than your personal use f.ex.). Please read [Ollama Setup Guide](./Ollama_Guide.md) if you struggle to get the Ollama connection running. 70 | 71 | ```bash 72 | touch .env 73 | code .env # open file with vscode 74 | nvim .env # open file with neovim 75 | ``` 76 | 77 | 3. Run the containers 78 | 79 | ```bash 80 | docker-compose up -d 81 | ``` 82 | 83 | -------------------------------------------------------------------------------- /README.md.old: -------------------------------------------------------------------------------- 1 | # LLocalSearch 2 | 3 | > [!IMPORTANT] 4 | > Discuss configurations and setups with other users at: https://discord.gg/Cm77Eav5mX. Help / Support is handled exclusively on GitHub to allow people with similar issues to find solutions more easily. 5 | 6 | ## What it is 7 | 8 | LLocalSearch is a completely locally running search aggregator using LLM Agents. The user can ask a question and the system will use a chain of LLMs to find the answer. The user can see the progress of the agents and the final answer. No OpenAI or Google API keys are needed. 9 | 10 | ### Demo 11 | 12 | https://github.com/nilsherzig/LLocalSearch/assets/72463901/86ab3175-ac5a-48cf-bba6-73b4380d06d8 13 | 14 | ## Features 15 | 16 | - 🕵️ Completely local (no need for API keys) 17 | - 💸 Runs on "low end" LLM Hardware (demo video uses a 7b model) 18 | - 🤓 Progress logs, allowing for a better understanding of the search process 19 | - 🤔 Follow-up questions 20 | - 📱 Mobile friendly interface 21 | - 🚀 Fast and easy to deploy with Docker Compose 22 | - 🌐 Web interface, allowing for easy access from any device 23 | - 💮 Handcrafted UI with light and dark mode 24 | 25 | ## Status 26 | 27 | This project is still in its very early days. Expect some bugs. 28 | 29 | ## How it works 30 | 31 | Please read [infra](https://github.com/nilsherzig/LLocalSearch/issues/17) to get the most up-to-date idea. 32 | 33 | ## Install 34 | 35 | ### Requirements 36 | 37 | - A running [Ollama](https://ollama.com/) server, reachable from the container 38 | - GPU is not needed, but recommended 39 | - Docker Compose 40 | 41 | > [!WARNING] 42 | > Please read [Ollama Setup Guide](./OLLAMA_GUIDE.md) to get Ollama working with LLocalSearch. 43 | 44 | ### Run the latest release 45 | 46 | Recommended, if you don't intend to develop on this project. 47 | 48 | ```bash 49 | git clone https://github.com/nilsherzig/LLocalSearch.git 50 | cd ./LLocalSearch 51 | # 🔴 check the env vars inside the compose file (and `env-example` file) and change them if needed 52 | docker-compose up 53 | ``` 54 | 55 | 🎉 You should now be able to open the web interface on http://localhost:3000. Nothing else is exposed by default. 56 | 57 | ### Run the development version 58 | 59 | Only recommended if you want to contribute to this project. 60 | 61 | ```bash 62 | git clone https://github.com/nilsherzig/LLocalsearch.git 63 | # 1. make sure to check the env vars inside the `docker-compose.dev.yaml`. 64 | # 2. Make sure you've really checked the dev compose file not the normal one. 65 | 66 | # 3. build the containers and start the services 67 | make dev 68 | # Both front and backend will hot reload on code changes. 69 | ``` 70 | 71 | If you don't have `make` installed, you can run the commands inside the Makefile manually. 72 | 73 | Now you should be able to access the frontend on [http://localhost:3000](http://localhost:3000). 74 | -------------------------------------------------------------------------------- /backend/.air.toml: -------------------------------------------------------------------------------- 1 | root = "." 2 | testdata_dir = "testdata" 3 | tmp_dir = "tmp" 4 | 5 | [build] 6 | args_bin = [] 7 | bin = "./tmp/main" 8 | cmd = "go build -o ./tmp/main ." 9 | delay = 1000 10 | exclude_dir = ["assets", "tmp", "vendor", "testdata"] 11 | exclude_file = [] 12 | exclude_regex = ["_test.go"] 13 | exclude_unchanged = false 14 | follow_symlink = false 15 | full_bin = "" 16 | include_dir = [] 17 | include_ext = ["go", "tpl", "tmpl", "html"] 18 | include_file = [] 19 | kill_delay = "0s" 20 | log = "build-errors.log" 21 | poll = false 22 | poll_interval = 0 23 | post_cmd = [] 24 | pre_cmd = [] 25 | rerun = false 26 | rerun_delay = 500 27 | send_interrupt = false 28 | stop_on_error = true 29 | 30 | [color] 31 | app = "" 32 | build = "yellow" 33 | main = "magenta" 34 | runner = "green" 35 | watcher = "cyan" 36 | 37 | [log] 38 | main_only = false 39 | time = false 40 | 41 | [misc] 42 | clean_on_exit = false 43 | 44 | [screen] 45 | clear_on_rebuild = false 46 | keep_scroll = true 47 | -------------------------------------------------------------------------------- /backend/Dockerfile: -------------------------------------------------------------------------------- 1 | # Stage 1: Build 2 | FROM golang:1.22 AS builder 3 | 4 | # Set the Current Working Directory inside the container 5 | WORKDIR /app 6 | 7 | # Copy go mod and sum files 8 | COPY go.mod go.sum ./ 9 | 10 | # Download all dependencies. Dependencies will be cached if the go.mod and go.sum files are not changed 11 | RUN go mod download 12 | 13 | # Copy the source from the current directory to the Working Directory inside the container 14 | COPY . . 15 | 16 | # Build the Go app 17 | RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o main . 18 | 19 | # Stage 2: Run 20 | FROM alpine:latest 21 | 22 | RUN apk --no-cache add ca-certificates 23 | 24 | WORKDIR /root/ 25 | 26 | # Copy the Pre-built binary file from the previous stage 27 | COPY --from=builder /app/main . 28 | 29 | # Expose port 8080 to the outside world 30 | EXPOSE 8080 31 | 32 | # Command to run the executable 33 | CMD ["./main"] 34 | -------------------------------------------------------------------------------- /backend/Dockerfile.dev: -------------------------------------------------------------------------------- 1 | FROM golang:alpine3.19 2 | 3 | WORKDIR /app 4 | RUN go install github.com/cosmtrek/air@latest 5 | EXPOSE 8080 6 | 7 | CMD ["air"] 8 | -------------------------------------------------------------------------------- /backend/agentChain.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | "os" 8 | "time" 9 | 10 | "github.com/nilsherzig/LLocalSearch/llm_tools" 11 | "github.com/nilsherzig/LLocalSearch/utils" 12 | "github.com/tmc/langchaingo/agents" 13 | "github.com/tmc/langchaingo/chains" 14 | "github.com/tmc/langchaingo/embeddings" 15 | "github.com/tmc/langchaingo/llms" 16 | "github.com/tmc/langchaingo/memory" 17 | "github.com/tmc/langchaingo/tools" 18 | "github.com/tmc/langchaingo/vectorstores/chroma" 19 | ) 20 | 21 | func startAgentChain(ctx context.Context, outputChan chan<- utils.HttpJsonStreamElement, clientSettings utils.ClientSettings) error { 22 | defer func() { 23 | if r := recover(); r != nil { 24 | slog.Error("Recovered from panic", "error", r) 25 | } 26 | }() 27 | 28 | if clientSettings.Session == "new" { 29 | clientSettings.Session = utils.GetSessionString() 30 | slog.Info("Created new session", "session", clientSettings.Session) 31 | } 32 | 33 | session := clientSettings.Session 34 | 35 | if sessions[session].Buffer == nil { 36 | sessions[session] = Session{ 37 | Title: clientSettings.Prompt, 38 | Buffer: memory.NewConversationWindowBuffer(clientSettings.ContextSize), 39 | } 40 | memory.NewChatMessageHistory() 41 | 42 | sessions[session].Buffer.ChatHistory.AddMessage(ctx, llms.SystemChatMessage{ 43 | Content: clientSettings.SystemMessage, 44 | }) 45 | 46 | outputChan <- utils.HttpJsonStreamElement{ 47 | StepType: utils.StepHandleNewSession, 48 | Session: session, 49 | Stream: false, 50 | } 51 | 52 | // TODO HACK remove this ugly workaround 53 | // initializes the vector db namespace 54 | // otherwise the go routine spam in the download func will 55 | // race the intialization 56 | llm, err := utils.NewOllamaEmbeddingLLM() 57 | if err != nil { 58 | return err 59 | } 60 | embeder, err := embeddings.NewEmbedder(llm) 61 | if err != nil { 62 | return err 63 | } 64 | _, errNs := chroma.New( 65 | chroma.WithChromaURL(os.Getenv("CHROMA_DB_URL")), 66 | chroma.WithEmbedder(embeder), 67 | chroma.WithDistanceFunction("cosine"), 68 | chroma.WithNameSpace(session), 69 | ) 70 | if errNs != nil { 71 | slog.Error("Error creating new vector db namespace", "error", errNs) 72 | return errNs 73 | } 74 | slog.Info("Initialized vector db namespace", "session", session) 75 | } 76 | mem := sessions[session].Buffer 77 | 78 | outputChan <- utils.HttpJsonStreamElement{ 79 | StepType: utils.StepHandleUserMessage, 80 | Stream: false, 81 | Message: clientSettings.Prompt, 82 | } 83 | 84 | neededModels := []string{utils.EmbeddingsModel, clientSettings.ModelName} 85 | for _, modelName := range neededModels { 86 | if err := utils.CheckIfModelExistsOrPull(modelName); err != nil { 87 | slog.Error("Model does not exist and could not be pulled", "model", modelName, "error", err) 88 | outputChan <- utils.HttpJsonStreamElement{ 89 | Message: fmt.Sprintf("Model %s does not exist and could not be pulled: %s", modelName, err.Error()), 90 | StepType: utils.StepHandleLlmError, 91 | Stream: false, 92 | } 93 | return err 94 | } 95 | } 96 | 97 | llm, err := utils.NewOllama(clientSettings.ModelName, clientSettings.ContextSize) 98 | if err != nil { 99 | slog.Error("Error creating new LLM", "error", err) 100 | return err 101 | } 102 | 103 | slog.Info("Starting agent chain", "session", session, "userQuery", clientSettings) 104 | startTime := time.Now() 105 | 106 | agentTools := []tools.Tool{ 107 | llm_tools.WebScrape{ 108 | CallbacksHandler: utils.CustomHandler{ 109 | OutputChan: outputChan, 110 | }, 111 | SessionString: session, 112 | Settings: clientSettings, 113 | }, 114 | 115 | llm_tools.WebSearch{ 116 | CallbacksHandler: utils.CustomHandler{ 117 | OutputChan: outputChan, 118 | }, 119 | SessionString: session, 120 | Settings: clientSettings, 121 | }, 122 | 123 | llm_tools.SearchVectorDB{ 124 | CallbacksHandler: utils.CustomHandler{OutputChan: outputChan}, 125 | SessionString: session, 126 | Settings: clientSettings, 127 | }, 128 | } 129 | 130 | mainExecutor := agents.NewExecutor( 131 | agents.NewConversationalAgent(llm, agentTools, agents.WithCallbacksHandler(utils.CustomHandler{OutputChan: outputChan})), 132 | agentTools, 133 | agents.WithParserErrorHandler(agents.NewParserErrorHandler(func(s string) string { 134 | outputChan <- utils.HttpJsonStreamElement{ 135 | Message: fmt.Sprintf("Parsing Error. %s", s), 136 | StepType: utils.StepHandleParseError, 137 | Stream: false, 138 | } 139 | slog.Error("Parsing Error", "error", s) 140 | return utils.ParsingErrorPrompt() 141 | })), 142 | agents.WithMaxIterations(clientSettings.MaxIterations), 143 | agents.WithMemory(mem), 144 | ) 145 | 146 | // TODO: replace this with something smarter 147 | // currently used to tell the frotend, that everything worked so far 148 | // and the request is now going to be send to ollama 149 | outputChan <- utils.HttpJsonStreamElement{ 150 | StepType: utils.StepHandleOllamaStart, 151 | } 152 | 153 | temp := clientSettings.Temperature 154 | 155 | originalAnswer, err := chains.Run(ctx, mainExecutor, clientSettings.Prompt, chains.WithTemperature(temp)) 156 | 157 | if err != nil { 158 | return err 159 | } 160 | slog.Info("GotFirstAnswer", "session", session, "userQuery", clientSettings, "answer", originalAnswer, "time", time.Since(startTime)) 161 | 162 | messages, err := mem.ChatHistory.Messages(ctx) 163 | if err != nil { 164 | return err 165 | } 166 | 167 | ans, err := llm.Call(ctx, fmt.Sprintf("Please create a three (3) word title for the following conversation. Dont write anything else. Respond in the following Fromat `title: [your 3 word title]`. Conversation: ```%v```", messages)) 168 | if err != nil { 169 | return err 170 | } 171 | 172 | slog.Info("GotTitleAnswer", "session", session, "answer", ans, "time", time.Since(startTime)) 173 | oldSession := sessions[session] 174 | oldSession.Title = ans 175 | sessions[session] = oldSession 176 | 177 | outputChan <- utils.HttpJsonStreamElement{ 178 | Close: true, 179 | Message: sessions[session].Title, 180 | } 181 | return nil 182 | } 183 | -------------------------------------------------------------------------------- /backend/apiServer.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log/slog" 7 | "net/http" 8 | "sort" 9 | "time" 10 | 11 | "github.com/nilsherzig/LLocalSearch/utils" 12 | ) 13 | 14 | func sendError(w http.ResponseWriter, message string, errorcode int) { 15 | w.Header().Set("Content-Type", "application/json") 16 | w.WriteHeader(errorcode) 17 | json.NewEncoder(w).Encode(utils.HttpError{Error: message}) 18 | } 19 | 20 | func streamHandler(w http.ResponseWriter, r *http.Request) { 21 | setCorsHeaders(w) // TODO not needed while proxied through frontend? 22 | 23 | if r.Method == "OPTIONS" { 24 | w.WriteHeader(http.StatusOK) 25 | return 26 | } 27 | 28 | if r.Method != "GET" { 29 | w.WriteHeader(http.StatusMethodNotAllowed) 30 | return 31 | } 32 | 33 | // parse request settings 34 | clientSettings, err := parseClientSettings(r) 35 | if err != nil { 36 | http.Error(w, fmt.Sprintf("%v", err), http.StatusBadRequest) 37 | return 38 | } 39 | 40 | // create the go channel which is used to push 41 | // logs from deeper inside the backend to 42 | // the http stream 43 | outputChan := make(chan utils.HttpJsonStreamElement, 100) 44 | defer close(outputChan) 45 | 46 | // start the actual agentchain 47 | go startAgentChain(r.Context(), outputChan, clientSettings) 48 | 49 | w.Header().Set("Content-Type", "text/event-stream") 50 | for { 51 | select { 52 | case output, ok := <-outputChan: 53 | if !ok { 54 | return 55 | } 56 | output.TimeStamp = time.Now().Unix() 57 | 58 | if output.StepType == utils.StepHandleNewSession { 59 | clientSettings.Session = output.Session 60 | } 61 | 62 | session, ok := sessions[clientSettings.Session] 63 | if ok { 64 | session.Elements = append(session.Elements, output) 65 | sessions[clientSettings.Session] = session 66 | } else { 67 | slog.Error("Session not found", "session", clientSettings.Session) 68 | } 69 | 70 | jsonString, err := json.Marshal(output) 71 | if err != nil { 72 | slog.Info("Error marshalling output", "error", err) 73 | } 74 | sse := fmt.Sprintf("data: %s\n\n", jsonString) 75 | _, writeErr := fmt.Fprintf(w, sse) 76 | if writeErr != nil { 77 | slog.Info("Error writing to response writer", "error", writeErr) 78 | return 79 | } 80 | if f, ok := w.(http.Flusher); ok { 81 | f.Flush() 82 | } 83 | case <-r.Context().Done(): 84 | slog.Info("Client disconnected") 85 | return 86 | } 87 | } 88 | } 89 | 90 | func modelsHandler(w http.ResponseWriter, r *http.Request) { 91 | setCorsHeaders(w) 92 | if r.Method == "OPTIONS" { 93 | w.WriteHeader(http.StatusOK) 94 | return 95 | } 96 | 97 | if r.Method != "GET" { 98 | w.WriteHeader(http.StatusMethodNotAllowed) 99 | return 100 | } 101 | 102 | models, err := utils.GetOllamaModelList() 103 | if err != nil { 104 | http.Error(w, "Error getting model list", http.StatusInternalServerError) 105 | slog.Error("Error getting models") 106 | return 107 | } 108 | 109 | // remove the currently used embeddings model 110 | // from the modellist 111 | // TODO: find a way to remove all embeddings models 112 | for i, model := range models { 113 | if model == utils.EmbeddingsModel { 114 | models = append(models[:i], models[i+1:]...) 115 | } 116 | } 117 | 118 | jsonModels, err := json.Marshal(models) 119 | if err != nil { 120 | http.Error(w, "Error marshalling model list", http.StatusInternalServerError) 121 | slog.Error("Error marshalling models") 122 | return 123 | } 124 | 125 | w.Header().Set("Content-Type", "application/json") 126 | w.Write(jsonModels) 127 | } 128 | 129 | // TODO improve the amount of data that is sent 130 | // currently 99% of the data is empty json keys haha 131 | func loadChatHistory(w http.ResponseWriter, r *http.Request) { 132 | time.Sleep(time.Millisecond * 200) 133 | setCorsHeaders(w) 134 | if r.Method == "OPTIONS" { 135 | w.WriteHeader(http.StatusOK) 136 | return 137 | } 138 | 139 | if r.Method != "GET" { 140 | w.WriteHeader(http.StatusMethodNotAllowed) 141 | return 142 | } 143 | 144 | requestChatId := r.PathValue("chatid") 145 | if requestChatId == "" { 146 | message := "No chatId provided" 147 | sendError(w, message, http.StatusBadRequest) 148 | slog.Error(message) 149 | return 150 | } 151 | 152 | chat, ok := sessions[requestChatId] 153 | if !ok { 154 | message := fmt.Sprintf("Chat with id %s not found", requestChatId) 155 | sendError(w, message, http.StatusInternalServerError) 156 | slog.Error(message) 157 | return 158 | } 159 | 160 | response := chat.Elements 161 | 162 | jsonChat, err := json.Marshal(response) 163 | if err != nil { 164 | message := "Error marshalling chat" 165 | sendError(w, message, http.StatusInternalServerError) 166 | slog.Error(message) 167 | return 168 | } 169 | 170 | slog.Info("Loaded Chat", "id", requestChatId, "message count", len(chat.Elements)) 171 | w.Header().Set("Content-Type", "application/json") 172 | w.Write(jsonChat) 173 | } 174 | 175 | func chatListHandler(w http.ResponseWriter, r *http.Request) { 176 | // time.Sleep(time.Millisecond * 120) 177 | setCorsHeaders(w) 178 | if r.Method == "OPTIONS" { 179 | w.WriteHeader(http.StatusOK) 180 | return 181 | } 182 | 183 | if r.Method != "GET" { 184 | w.WriteHeader(http.StatusMethodNotAllowed) 185 | return 186 | } 187 | 188 | chatIds := []utils.ChatListItem{} 189 | for sessionid := range sessions { 190 | chatIds = append(chatIds, utils.ChatListItem{ 191 | SessionId: sessionid, 192 | Title: sessions[sessionid].Title, 193 | }) 194 | } 195 | // sort chatIds by timestamp 196 | // TODO HACK this is wildly inefficient 197 | sort.Slice(chatIds, func(i, j int) bool { 198 | iLen := len(sessions[chatIds[i].SessionId].Elements) 199 | jLen := len(sessions[chatIds[j].SessionId].Elements) 200 | return sessions[chatIds[i].SessionId].Elements[iLen-1].TimeStamp > sessions[chatIds[j].SessionId].Elements[jLen-1].TimeStamp 201 | }) 202 | 203 | jsonChatIds, err := json.Marshal(chatIds) 204 | if err != nil { 205 | message := "Error marshalling chatIds" 206 | sendError(w, message, http.StatusInternalServerError) 207 | slog.Error(message) 208 | return 209 | } 210 | 211 | w.Header().Set("Content-Type", "application/json") 212 | w.Write(jsonChatIds) 213 | slog.Info("Chat list sent") 214 | } 215 | 216 | func StartApiServer() { 217 | http.HandleFunc("/stream", streamHandler) 218 | http.HandleFunc("/models", modelsHandler) 219 | http.HandleFunc("/chat/{chatid}", loadChatHistory) 220 | http.HandleFunc("/chats/", chatListHandler) 221 | 222 | slog.Info("Starting server at http://localhost:8080") 223 | if err := http.ListenAndServe(":8080", nil); err != nil { 224 | slog.Error("Error starting server", "error", err) 225 | } 226 | } 227 | 228 | func setCorsHeaders(w http.ResponseWriter) { 229 | w.Header().Set("Access-Control-Allow-Origin", "*") 230 | w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") 231 | w.Header().Set("Access-Control-Allow-Headers", "Content-Type") 232 | } 233 | 234 | func parseClientSettings(r *http.Request) (utils.ClientSettings, error) { 235 | body := r.URL.Query().Get("settings") 236 | if body == "" { 237 | slog.Error("no settings json provided") 238 | return utils.ClientSettings{}, fmt.Errorf("no settings json provided") 239 | } 240 | 241 | clientSettings := utils.ClientSettings{} 242 | err := json.Unmarshal([]byte(body), &clientSettings) 243 | if err != nil { 244 | slog.Error("error parsing request body json", "error", err) 245 | return utils.ClientSettings{}, err 246 | } 247 | 248 | slog.Info("Client settings", "settings", clientSettings) 249 | return clientSettings, nil 250 | } 251 | -------------------------------------------------------------------------------- /backend/e2e/e2e_suite_test.go: -------------------------------------------------------------------------------- 1 | package e2e_test 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | func TestE2e(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "E2e Suite") 13 | } 14 | -------------------------------------------------------------------------------- /backend/e2e/simple_question_test.go: -------------------------------------------------------------------------------- 1 | package e2e_test 2 | 3 | import ( 4 | "bufio" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "net/url" 10 | "os" 11 | "os/exec" 12 | "strings" 13 | 14 | "github.com/nilsherzig/LLocalSearch/utils" 15 | . "github.com/onsi/ginkgo/v2" 16 | . "github.com/onsi/gomega" 17 | ) 18 | 19 | var _ = Describe("Main", Ordered, func() { 20 | Describe("tests one shot questions with known answers", func() { 21 | // BeforeAll(func() { 22 | // cmd := exec.Command("make", "dev-bg") 23 | // _, err := Run(cmd) 24 | // if err != nil { 25 | // if exitErr, ok := err.(*exec.ExitError); ok { 26 | // GinkgoWriter.Printf("Stderr: %s\n", string(exitErr.Stderr)) 27 | // } 28 | // } 29 | // 30 | // Expect(err).NotTo(HaveOccurred(), fmt.Sprintf("start command '%s' failed", cmd.String())) 31 | // 32 | // DeferCleanup(func() { 33 | // cmd := exec.Command("make", "dev-bg-stop") 34 | // _, err := Run(cmd) 35 | // Expect(err).NotTo(HaveOccurred()) 36 | // }) 37 | // }) 38 | 39 | It("should be able to get the modellist endpoint", func() { 40 | Eventually(func() error { 41 | req, err := http.NewRequest("GET", "http://localhost:3000/api/models", nil) 42 | Expect(err).ToNot(HaveOccurred()) 43 | 44 | resp, err := http.DefaultClient.Do(req) 45 | if err != nil { 46 | return err 47 | } 48 | if resp.StatusCode != http.StatusOK { 49 | return fmt.Errorf("status code not 200") 50 | } 51 | return nil 52 | }, "2m", "5s").Should(Not(HaveOccurred())) 53 | 54 | }) 55 | 56 | defaultModel := "adrienbrault/nous-hermes2pro:Q8_0" 57 | // defaultModel = "command-r:35b-v0.1-q4_0" 58 | 59 | sessionString := "default" 60 | 61 | DescribeTable("questions and answers", func(prompt string, answerSubstring string, modelname string) { 62 | requestUrl := fmt.Sprintf("http://localhost:3000/api/stream?prompt=%s&session=%s&modelname=%s", url.QueryEscape(prompt), url.QueryEscape(sessionString), url.QueryEscape(modelname)) 63 | resp, err := http.Get(requestUrl) 64 | Expect(err).ToNot(HaveOccurred(), "stream request failed") 65 | Expect(resp.StatusCode).To(Equal(http.StatusOK), "stream request http status code not 200") 66 | 67 | reader := bufio.NewReader(resp.Body) 68 | inner: 69 | for { 70 | line, err := reader.ReadString('\n') 71 | if err == io.EOF { 72 | break 73 | } 74 | line = strings.TrimLeft(line, "data: ") 75 | var streamElem utils.HttpJsonStreamElement 76 | err = json.Unmarshal([]byte(line), &streamElem) 77 | if err != nil { 78 | continue 79 | } 80 | // TOOD Needed for follow-up quesions 81 | // if streamElem.Session != sessionString { 82 | // sessionString = streamElem.Session 83 | // continue 84 | // } 85 | if streamElem.StepType == utils.StepHandleAgentFinish { 86 | GinkgoWriter.Printf("line: %s\n", line) 87 | Expect(streamElem.Message).To(ContainSubstring(answerSubstring), "answer substring not found in response") 88 | break inner 89 | } 90 | } 91 | }, 92 | Entry("German quesion", "Wann beginnt das Sommersemester an der Hochschule Stralsund?", "März", defaultModel), 93 | Entry("Fact 1 question", "how much do OpenAI and Microsoft plan to spend on their new datacenter?", "$100 billion", defaultModel), 94 | Entry("Fact 2", "how much does Obsidian sync cost?", "$4", defaultModel), 95 | ) 96 | }) 97 | }) 98 | 99 | func Run(cmd *exec.Cmd) ([]byte, error) { 100 | dir, _ := GetProjectDir() 101 | cmd.Dir = dir 102 | 103 | if err := os.Chdir(cmd.Dir); err != nil { 104 | fmt.Fprintf(GinkgoWriter, "chdir dir: %s\n", err) 105 | } 106 | 107 | cmd.Env = append(os.Environ(), "GO111MODULE=on") 108 | command := strings.Join(cmd.Args, " ") 109 | fmt.Fprintf(GinkgoWriter, "running: %s\n", command) 110 | output, err := cmd.CombinedOutput() 111 | if err != nil { 112 | return output, fmt.Errorf("%s failed with error: (%v) %s", command, err, string(output)) 113 | } 114 | 115 | return output, nil 116 | } 117 | 118 | func GetProjectDir() (string, error) { 119 | wd, err := os.Getwd() 120 | if err != nil { 121 | return wd, err 122 | } 123 | wd = strings.Replace(wd, "/backend/e2e", "", -1) 124 | return wd, nil 125 | } 126 | -------------------------------------------------------------------------------- /backend/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/nilsherzig/LLocalSearch 2 | 3 | go 1.22.0 4 | 5 | toolchain go1.22.1 6 | 7 | replace github.com/tmc/langchaingo => github.com/nilsherzig/langchaingo v1.99.99 8 | 9 | require ( 10 | github.com/google/uuid v1.6.0 11 | github.com/onsi/ginkgo/v2 v2.17.1 12 | github.com/onsi/gomega v1.30.0 13 | github.com/tmc/langchaingo v0.1.8 14 | ) 15 | 16 | require ( 17 | github.com/go-logr/logr v1.4.1 // indirect 18 | github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect 19 | github.com/google/go-cmp v0.6.0 // indirect 20 | github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 // indirect 21 | github.com/lmittmann/tint v1.0.4 22 | golang.org/x/tools v0.17.0 // indirect 23 | ) 24 | 25 | require ( 26 | github.com/AssemblyAI/assemblyai-go-sdk v1.3.0 // indirect 27 | github.com/Masterminds/goutils v1.1.1 // indirect 28 | github.com/Masterminds/semver v1.5.0 // indirect 29 | github.com/Masterminds/semver/v3 v3.2.0 // indirect 30 | github.com/Masterminds/sprig/v3 v3.2.3 // indirect 31 | github.com/PuerkitoBio/goquery v1.8.1 // indirect 32 | github.com/amikos-tech/chroma-go v0.1.2 // indirect 33 | github.com/andybalholm/cascadia v1.3.2 // indirect 34 | github.com/aymerick/douceur v0.2.0 // indirect 35 | github.com/cenkalti/backoff v2.2.1+incompatible // indirect 36 | github.com/dlclark/regexp2 v1.10.0 // indirect 37 | github.com/dustin/go-humanize v1.0.1 // indirect 38 | github.com/google/go-querystring v1.1.0 // indirect 39 | github.com/goph/emperror v0.17.2 // indirect 40 | github.com/gorilla/css v1.0.0 // indirect 41 | github.com/huandu/xstrings v1.3.3 // indirect 42 | github.com/imdario/mergo v0.3.13 // indirect 43 | github.com/json-iterator/go v1.1.12 // indirect 44 | github.com/klauspost/compress v1.17.2 // indirect 45 | github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 // indirect 46 | github.com/microcosm-cc/bluemonday v1.0.26 47 | github.com/mitchellh/copystructure v1.0.0 // indirect 48 | github.com/mitchellh/reflectwalk v1.0.0 // indirect 49 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 50 | github.com/modern-go/reflect2 v1.0.2 // indirect 51 | github.com/nikolalohinski/gonja v1.5.3 // indirect 52 | github.com/oklog/ulid v1.3.1 // indirect 53 | github.com/ollama/ollama v0.1.30 54 | github.com/pelletier/go-toml/v2 v2.0.9 // indirect 55 | github.com/pkg/errors v0.9.1 // indirect 56 | github.com/pkoukk/tiktoken-go v0.1.6 // indirect 57 | github.com/shopspring/decimal v1.2.0 // indirect 58 | github.com/sirupsen/logrus v1.9.3 // indirect 59 | github.com/spf13/cast v1.3.1 // indirect 60 | github.com/yargevad/filepathx v1.0.0 // indirect 61 | gitlab.com/golang-commonmark/html v0.0.0-20191124015941-a22733972181 // indirect 62 | gitlab.com/golang-commonmark/linkify v0.0.0-20191026162114-a0c2df6c8f82 // indirect 63 | gitlab.com/golang-commonmark/markdown v0.0.0-20211110145824-bf3e522c626a // indirect 64 | gitlab.com/golang-commonmark/mdurl v0.0.0-20191124015652-932350d1cb84 // indirect 65 | gitlab.com/golang-commonmark/puny v0.0.0-20191124015043-9f83538fa04f // indirect 66 | go.starlark.net v0.0.0-20230302034142-4b1e35fe2254 // indirect 67 | golang.org/x/crypto v0.21.0 // indirect 68 | golang.org/x/exp v0.0.0-20230817173708-d852ddb80c63 // indirect 69 | golang.org/x/net v0.22.0 // indirect 70 | golang.org/x/sys v0.18.0 // indirect 71 | golang.org/x/text v0.14.0 // indirect 72 | gopkg.in/yaml.v3 v3.0.1 // indirect 73 | nhooyr.io/websocket v1.8.7 // indirect 74 | ) 75 | -------------------------------------------------------------------------------- /backend/llm_tools/simple_websearch.go: -------------------------------------------------------------------------------- 1 | package llm_tools 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "log/slog" 8 | "net/http" 9 | "net/url" 10 | "os" 11 | "strings" 12 | "sync" 13 | 14 | "github.com/nilsherzig/LLocalSearch/utils" 15 | "github.com/tmc/langchaingo/callbacks" 16 | "github.com/tmc/langchaingo/tools" 17 | ) 18 | 19 | type WebSearch struct { 20 | CallbacksHandler callbacks.Handler 21 | SessionString string 22 | Settings utils.ClientSettings 23 | } 24 | 25 | var simpleUsedLinks = make(map[string][]string) 26 | 27 | var _ tools.Tool = WebSearch{} 28 | 29 | func (c WebSearch) Description() string { 30 | return `Use this tool to search for websites that may answer your search query. A summary of the websites will be returned to you directly. This might be enough to answer simple questions.` 31 | } 32 | 33 | func (c WebSearch) Name() string { 34 | return "websearch" 35 | } 36 | 37 | func (ws WebSearch) Call(ctx context.Context, input string) (string, error) { 38 | if ws.CallbacksHandler != nil { 39 | ws.CallbacksHandler.HandleToolStart(ctx, input) 40 | } 41 | 42 | input = strings.TrimPrefix(input, "\"") 43 | input = strings.TrimSuffix(input, "\"") 44 | inputQuery := url.QueryEscape(input) 45 | searXNGDomain := os.Getenv("SEARXNG_DOMAIN") 46 | url := fmt.Sprintf("%s/?q=%s&format=json", searXNGDomain, inputQuery) 47 | resp, err := http.Get(url) 48 | 49 | if err != nil { 50 | slog.Warn("Error making the request", "error", err) 51 | return "", err 52 | } 53 | defer resp.Body.Close() 54 | 55 | var apiResponse utils.SeaXngResult 56 | if err := json.NewDecoder(resp.Body).Decode(&apiResponse); err != nil { 57 | slog.Warn("Error decoding the response", "error", err) 58 | return "", err 59 | } 60 | 61 | wg := sync.WaitGroup{} 62 | counter := 0 63 | summaryResults := []string{} 64 | for i := range apiResponse.Results { 65 | skip := false 66 | for _, usedLink := range simpleUsedLinks[ws.SessionString] { 67 | if usedLink == apiResponse.Results[i].URL { 68 | slog.Warn("Skipping already used link during SimpleWebSearch", "link", apiResponse.Results[i].URL) 69 | skip = true 70 | break 71 | } 72 | } 73 | if skip { 74 | continue 75 | } 76 | 77 | if counter >= ws.Settings.AmountOfWebsites { 78 | break 79 | } 80 | 81 | if strings.HasSuffix(apiResponse.Results[i].URL, ".pdf") { 82 | continue 83 | } 84 | 85 | summaryResults = append(summaryResults, apiResponse.Results[i].Content) 86 | 87 | counter += 1 88 | wg.Add(1) 89 | go func(i int) { 90 | defer func() { 91 | wg.Done() 92 | if r := recover(); r != nil { 93 | slog.Error("Recovered from panic", "error", r) 94 | } 95 | }() 96 | 97 | ch, ok := ws.CallbacksHandler.(utils.CustomHandler) 98 | if ok { 99 | newSource := utils.Source{ 100 | Name: "SimpleWebSearch", 101 | Link: apiResponse.Results[i].URL, 102 | Summary: apiResponse.Results[i].Content, 103 | Title: apiResponse.Results[i].Title, 104 | Engine: apiResponse.Results[i].Engine, 105 | } 106 | 107 | ch.HandleSourceAdded(ctx, newSource) 108 | } 109 | }(i) 110 | simpleUsedLinks[ws.SessionString] = append(simpleUsedLinks[ws.SessionString], apiResponse.Results[i].URL) 111 | } 112 | wg.Wait() 113 | 114 | result, err := json.Marshal(summaryResults) 115 | 116 | if err != nil { 117 | return "", err 118 | } 119 | 120 | if ws.CallbacksHandler != nil { 121 | ws.CallbacksHandler.HandleToolEnd(ctx, string(result)) 122 | } 123 | 124 | if len(apiResponse.Results) == 0 { 125 | return "No results found", fmt.Errorf("No results, we might be rate limited") 126 | } 127 | 128 | return string(result), nil 129 | } 130 | -------------------------------------------------------------------------------- /backend/llm_tools/tool_search_vector_db.go: -------------------------------------------------------------------------------- 1 | package llm_tools 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "log/slog" 8 | "net/url" 9 | "os" 10 | 11 | "github.com/nilsherzig/LLocalSearch/utils" 12 | "github.com/tmc/langchaingo/callbacks" 13 | "github.com/tmc/langchaingo/embeddings" 14 | "github.com/tmc/langchaingo/schema" 15 | "github.com/tmc/langchaingo/tools" 16 | "github.com/tmc/langchaingo/vectorstores" 17 | "github.com/tmc/langchaingo/vectorstores/chroma" 18 | ) 19 | 20 | // ReadWebsite is a tool that can do math. 21 | type SearchVectorDB struct { 22 | CallbacksHandler callbacks.Handler 23 | SessionString string 24 | Settings utils.ClientSettings 25 | } 26 | 27 | var _ tools.Tool = SearchVectorDB{} 28 | 29 | type Result struct { 30 | Text string 31 | } 32 | 33 | var usedResults = make(map[string][]string) 34 | var usedSourcesInSession = make(map[string][]schema.Document) 35 | 36 | func (c SearchVectorDB) Description() string { 37 | return "Use this tool to search through already added files or websites within a vector database. The most similar websites or documents to your input will be returned to you." 38 | } 39 | 40 | func (c SearchVectorDB) Name() string { 41 | return "database_search" 42 | } 43 | 44 | func (c SearchVectorDB) Call(ctx context.Context, input string) (string, error) { 45 | if c.CallbacksHandler != nil { 46 | c.CallbacksHandler.HandleToolStart(ctx, input) 47 | } 48 | 49 | searchIdentifier := fmt.Sprintf("%s-%s", c.SessionString, input) 50 | 51 | llm, err := utils.NewOllamaEmbeddingLLM() 52 | if err != nil { 53 | return "", err 54 | } 55 | 56 | ollamaEmbeder, err := embeddings.NewEmbedder(llm) 57 | if err != nil { 58 | return "", err 59 | } 60 | 61 | store, errNs := chroma.New( 62 | chroma.WithChromaURL(os.Getenv("CHROMA_DB_URL")), 63 | chroma.WithEmbedder(ollamaEmbeder), 64 | chroma.WithDistanceFunction("cosine"), 65 | chroma.WithNameSpace(c.SessionString), 66 | ) 67 | 68 | if errNs != nil { 69 | return "", errNs 70 | } 71 | 72 | options := []vectorstores.Option{ 73 | vectorstores.WithScoreThreshold(float32(c.Settings.MinResultScore)), 74 | } 75 | 76 | retriver := vectorstores.ToRetriever(store, c.Settings.AmountOfResults, options...) 77 | docs, err := retriver.GetRelevantDocuments(context.Background(), input) 78 | if err != nil { 79 | return "", err 80 | } 81 | 82 | var results []Result 83 | 84 | for _, doc := range docs { 85 | newResult := Result{ 86 | Text: doc.PageContent, 87 | } 88 | 89 | skip := false 90 | for _, usedLink := range usedResults[searchIdentifier] { 91 | if usedLink == newResult.Text { 92 | skip = true 93 | break 94 | } 95 | } 96 | if skip { 97 | continue 98 | } 99 | 100 | usedSourcesInSession[c.SessionString] = append(usedSourcesInSession[c.SessionString], doc) 101 | 102 | ch, ok := c.CallbacksHandler.(utils.CustomHandler) 103 | if ok { 104 | slog.Info("found vector", "source", doc.Metadata["URL"]) 105 | ch.HandleSourceAdded(ctx, utils.Source{ 106 | Name: "DatabaseSearch", 107 | Link: "none", 108 | Summary: doc.PageContent, 109 | Engine: "DatabaseSearch", 110 | Title: "DatabaseSearch", 111 | }) 112 | } 113 | results = append(results, newResult) 114 | usedResults[searchIdentifier] = append(usedResults[searchIdentifier], newResult.Text) 115 | } 116 | 117 | if len(docs) == 0 { 118 | response := "No new results found. Try other db search keywords, download more websites or write your final answer." 119 | slog.Warn("No new results found", "input", input) 120 | results = append(results, Result{Text: response}) 121 | } 122 | 123 | if c.CallbacksHandler != nil { 124 | c.CallbacksHandler.HandleToolEnd(ctx, input) 125 | } 126 | 127 | resultJson, err := json.Marshal(results) 128 | if err != nil { 129 | return "", err 130 | } 131 | 132 | return string(resultJson), nil 133 | } 134 | 135 | func extractBaseDomain(inputURL string) (string, error) { 136 | parsedURL, err := url.Parse(inputURL) 137 | if err != nil { 138 | return "", err 139 | } 140 | return parsedURL.Host, nil 141 | } 142 | -------------------------------------------------------------------------------- /backend/llm_tools/tool_webscrape.go: -------------------------------------------------------------------------------- 1 | package llm_tools 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "log/slog" 8 | "net/http" 9 | "net/url" 10 | "os" 11 | "strings" 12 | "sync" 13 | 14 | "github.com/nilsherzig/LLocalSearch/utils" 15 | "github.com/tmc/langchaingo/callbacks" 16 | "github.com/tmc/langchaingo/tools" 17 | ) 18 | 19 | type WebScrape struct { 20 | CallbacksHandler callbacks.Handler 21 | SessionString string 22 | Settings utils.ClientSettings 23 | } 24 | 25 | var usedLinks = make(map[string][]string) 26 | 27 | var _ tools.Tool = WebScrape{} 28 | 29 | func (c WebScrape) Description() string { 30 | return `Use this tool to search for websites that may answer your search query. The best websites (according to the search engine) are broken down into small parts and added to your vector database. 31 | 32 | The parts of these websites that are most similar to your search query will be returned to you directly. 33 | 34 | You can query the vector database later with other inputs to get other parts of these websites.` 35 | } 36 | 37 | func (c WebScrape) Name() string { 38 | return "webscrape" 39 | } 40 | 41 | func (ws WebScrape) Call(ctx context.Context, input string) (string, error) { 42 | if ws.CallbacksHandler != nil { 43 | ws.CallbacksHandler.HandleToolStart(ctx, input) 44 | } 45 | 46 | input = strings.TrimPrefix(input, "\"") 47 | input = strings.TrimSuffix(input, "\"") 48 | inputQuery := url.QueryEscape(input) 49 | searXNGDomain := os.Getenv("SEARXNG_DOMAIN") 50 | url := fmt.Sprintf("%s/?q=%s&format=json", searXNGDomain, inputQuery) 51 | resp, err := http.Get(url) 52 | 53 | if err != nil { 54 | slog.Warn("Error making the request", "error", err) 55 | return "", err 56 | } 57 | defer resp.Body.Close() 58 | 59 | var apiResponse utils.SeaXngResult 60 | if err := json.NewDecoder(resp.Body).Decode(&apiResponse); err != nil { 61 | slog.Warn("Error decoding the response", "error", err) 62 | return "", err 63 | } 64 | 65 | wg := sync.WaitGroup{} 66 | counter := 0 67 | for i := range apiResponse.Results { 68 | skip := false 69 | for _, usedLink := range usedLinks[ws.SessionString] { 70 | if usedLink == apiResponse.Results[i].URL { 71 | skip = true 72 | break 73 | } 74 | } 75 | 76 | if skip { 77 | continue 78 | } 79 | 80 | if counter >= ws.Settings.AmountOfWebsites { 81 | break 82 | } 83 | 84 | // if result link ends in .pdf, skip 85 | if strings.HasSuffix(apiResponse.Results[i].URL, ".pdf") { 86 | continue 87 | } 88 | 89 | counter += 1 90 | wg.Add(1) 91 | go func(i int) { 92 | defer func() { 93 | wg.Done() 94 | if r := recover(); r != nil { 95 | slog.Error("Recovered from panic", "error", r) 96 | } 97 | }() 98 | 99 | err := utils.DownloadWebsiteToVectorDB(context.Background(), apiResponse.Results[i].URL, ws.SessionString, ws.Settings.ChunkSize, ws.Settings.ChunkOverlap) 100 | if err != nil { 101 | slog.Warn("Error downloading website", "error", err) 102 | return 103 | } 104 | ch, ok := ws.CallbacksHandler.(utils.CustomHandler) 105 | if ok { 106 | newSource := utils.Source{ 107 | Name: "WebSearch", 108 | Link: apiResponse.Results[i].URL, 109 | Summary: apiResponse.Results[i].Content, 110 | Title: apiResponse.Results[i].Title, 111 | Engine: apiResponse.Results[i].Engine, 112 | } 113 | 114 | ch.HandleSourceAdded(ctx, newSource) 115 | } 116 | }(i) 117 | usedLinks[ws.SessionString] = append(usedLinks[ws.SessionString], apiResponse.Results[i].URL) 118 | } 119 | wg.Wait() 120 | svb := SearchVectorDB{ 121 | CallbacksHandler: ws.CallbacksHandler, 122 | SessionString: ws.SessionString, 123 | Settings: ws.Settings, 124 | } 125 | result, err := svb.Call(context.Background(), input) 126 | if err != nil { 127 | return fmt.Sprintf("error from vector db search: %s", err.Error()), nil 128 | } 129 | 130 | if ws.CallbacksHandler != nil { 131 | ws.CallbacksHandler.HandleToolEnd(ctx, result) 132 | } 133 | 134 | if len(apiResponse.Results) == 0 { 135 | return "No results found", fmt.Errorf("No results, we might be rate limited") 136 | } 137 | 138 | return result, nil 139 | } 140 | -------------------------------------------------------------------------------- /backend/lschains/custom_structured_parser.go: -------------------------------------------------------------------------------- 1 | package lschains 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/tmc/langchaingo/llms" 9 | "github.com/tmc/langchaingo/schema" 10 | ) 11 | 12 | // ParseError is the error type returned by output parsers. 13 | type ParseError struct { 14 | Text string 15 | Reason string 16 | } 17 | 18 | func (e ParseError) Error() string { 19 | return fmt.Sprintf("parse text %s. %s", e.Text, e.Reason) 20 | } 21 | 22 | const ( 23 | // _structuredFormatInstructionTemplate is a template for the format 24 | // instructions of the structured output parser. 25 | _structuredFormatInstructionTemplate = "The output should be a markdown code snippet formatted in the following schema: \n```json\n[\n{\n%s}\n],\n```" // nolint 26 | 27 | // _structuredLineTemplate is a single line of the json schema in the 28 | // format instruction of the structured output parser. The fist verb is 29 | // the name, the second verb is the type and the third is a description of 30 | // what the field should contain. 31 | _structuredLineTemplate = "\"%s\": %s // %s\n" 32 | ) 33 | 34 | // ResponseSchema is struct used in the structured output parser to describe 35 | // how the llm should format its response. Name is a key in the parsed 36 | // output map. Description is a description of what the value should contain. 37 | type ResponseSchema struct { 38 | Name string 39 | Description string 40 | } 41 | 42 | // Structured is an output parser that parses the output of an LLM into key value 43 | // pairs. The name and description of what values the output of the llm should 44 | // contain is stored in a list of response schema. 45 | type Structured struct { 46 | ResponseSchemas []ResponseSchema 47 | } 48 | 49 | // NewStructured is a function that creates a new structured output parser from 50 | // a list of response schemas. 51 | func NewStructured(schema []ResponseSchema) Structured { 52 | return Structured{ 53 | ResponseSchemas: schema, 54 | } 55 | } 56 | 57 | // Statically assert that Structured implement the OutputParser interface. 58 | var _ schema.OutputParser[any] = Structured{} 59 | 60 | // Parse parses the output of an LLM into a map. If the output of the llm doesn't 61 | // contain every filed specified in the response schemas, the function will return 62 | // an error. 63 | func (p Structured) parse(text string) ([]ResponseSchema, error) { 64 | // Remove the ```json that should be at the start of the text, and the ``` 65 | // that should be at the end of the text. 66 | 67 | // some models really suck at this 68 | possibleStarts := []string{"```json", "```\njson", "```"} 69 | startString := "" 70 | for _, start := range possibleStarts { 71 | if strings.Contains(text, start) { 72 | startString = start 73 | break 74 | } 75 | } 76 | if startString == "" { 77 | return nil, ParseError{Text: text, Reason: "no valid start string in output"} 78 | } 79 | 80 | withoutJSONStart := strings.Split(text, startString) 81 | if !(len(withoutJSONStart) > 1) { 82 | return nil, ParseError{Text: text, Reason: "no ```json at start of output"} 83 | } 84 | 85 | withoutJSONEnd := strings.Split(withoutJSONStart[1], "```") 86 | if len(withoutJSONEnd) < 1 { 87 | return nil, ParseError{Text: text, Reason: "no ``` at end of output"} 88 | } 89 | 90 | jsonString := withoutJSONEnd[0] 91 | // slog.Info("source reponse", "jsonString", jsonString) 92 | fmt.Printf("%v", jsonString) 93 | 94 | var parsed []map[string]string 95 | err := json.Unmarshal([]byte(jsonString), &parsed) 96 | if err != nil { 97 | return nil, err 98 | } 99 | 100 | result := []ResponseSchema{} 101 | 102 | for _, p := range parsed { 103 | if p[PartKey] == "" || p[SourceKey] == "" { 104 | continue 105 | } 106 | result = append(result, ResponseSchema{ 107 | Name: p[PartKey], 108 | Description: p[SourceKey], 109 | }) 110 | } 111 | 112 | // Validate that the parsed map contains all fields specified in the response 113 | // schemas. 114 | missingKeys := make([]string, 0) 115 | // for _, rs := range p.ResponseSchemas { 116 | // if _, ok := parsed[rs.Name]; !ok { 117 | // missingKeys = append(missingKeys, rs.Name) 118 | // } 119 | // } 120 | 121 | if len(missingKeys) > 0 { 122 | return nil, ParseError{ 123 | Text: text, 124 | Reason: fmt.Sprintf("output is missing the following fields %v", missingKeys), 125 | } 126 | } 127 | 128 | return result, nil 129 | } 130 | 131 | func (p Structured) Parse(text string) (any, error) { 132 | return p.parse(text) 133 | } 134 | 135 | // ParseWithPrompt does the same as Parse. 136 | func (p Structured) ParseWithPrompt(text string, _ llms.PromptValue) (any, error) { 137 | return p.parse(text) 138 | } 139 | 140 | // GetFormatInstructions returns a string explaining how the llm should format 141 | // its response. 142 | func (p Structured) GetFormatInstructions() string { 143 | jsonLines := "" 144 | for _, rs := range p.ResponseSchemas { 145 | jsonLines += "\t" + fmt.Sprintf( 146 | _structuredLineTemplate, 147 | rs.Name, 148 | "string", /* type of the filed*/ 149 | rs.Description, 150 | ) 151 | } 152 | 153 | return fmt.Sprintf(_structuredFormatInstructionTemplate, jsonLines) 154 | } 155 | 156 | // Type returns the type of the output parser. 157 | func (p Structured) Type() string { 158 | return "structured_parser" 159 | } 160 | -------------------------------------------------------------------------------- /backend/lschains/format_sources_chain.go: -------------------------------------------------------------------------------- 1 | package lschains 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "log/slog" 8 | "strings" 9 | "time" 10 | 11 | "github.com/tmc/langchaingo/callbacks" 12 | "github.com/tmc/langchaingo/chains" 13 | "github.com/tmc/langchaingo/llms/ollama" 14 | "github.com/tmc/langchaingo/memory" 15 | "github.com/tmc/langchaingo/prompts" 16 | "github.com/tmc/langchaingo/schema" 17 | ) 18 | 19 | const ( 20 | PartKey = "Quote" 21 | SourceKey = "ID" 22 | ) 23 | 24 | const outputKey = "text" 25 | const _formatPromptTemplate = `Its your task to enhance the "old answer" with sources. To do this, you pick a quote from the old answer and find a source that supports it. 26 | 27 | The old Answer without sources (only quote from this): 28 | ----------------- 29 | {{.oldAnswer}} 30 | ----------------- 31 | 32 | Sources: 33 | ----------------- 34 | {{.sources}} 35 | ----------------- 36 | 37 | {{.format}} 38 | Provide at least three quotes from the sources and the source ID. 39 | 40 | Your answer: 41 | ` 42 | 43 | func RunSourceChain(llm *ollama.LLM, sources []schema.Document, textWithoutSources string) (string, error) { 44 | startTime := time.Now() 45 | 46 | // reducing the amount of tokens in the context window 47 | sourceContentIdMap := make(map[string]string) 48 | for i, source := range sources { 49 | sourceContentIdMap[fmt.Sprintf("s%d", i)] = source.PageContent 50 | } 51 | 52 | // converting the go map to json, since llms have seen a lot more json 53 | // during training - resulting in better responses 54 | sourcesJson, err := json.Marshal(sourceContentIdMap) 55 | if err != nil { 56 | slog.Error("Error marshalling source map", "error", err) 57 | return "", err 58 | } 59 | 60 | rs := []ResponseSchema{ 61 | { 62 | Name: PartKey, 63 | Description: "A very short (3 words) verbatim quote from the old answer.", 64 | }, 65 | { 66 | Name: SourceKey, 67 | Description: "The related source ID.", 68 | }, 69 | } 70 | 71 | parser := NewStructured(rs) 72 | 73 | cps := chains.ConditionalPromptSelector{ 74 | DefaultPrompt: prompts.NewPromptTemplate(_formatPromptTemplate, []string{"format", "sources", "oldAnswer"}), 75 | } 76 | 77 | formatChain := chains.LLMChain{ 78 | Prompt: cps.DefaultPrompt, 79 | LLM: llm, 80 | Memory: memory.NewConversationBuffer(), 81 | CallbacksHandler: callbacks.LogHandler{}, 82 | OutputParser: parser, 83 | OutputKey: outputKey, 84 | } 85 | 86 | valueMap, err := chains.Call(context.Background(), formatChain, map[string]any{ 87 | "format": parser.GetFormatInstructions(), 88 | "sources": string(sourcesJson), 89 | "oldAnswer": textWithoutSources, 90 | }, 91 | chains.WithTemperature(0.0), 92 | chains.WithTopK(1), 93 | ) 94 | 95 | out, ok := valueMap[outputKey].([]ResponseSchema) 96 | if !ok { 97 | slog.Error("type assertion failed", "value", valueMap[outputKey]) 98 | return "", fmt.Errorf("type assertion failed") 99 | } 100 | matches := 0 101 | failed := 0 102 | textWithSources := textWithoutSources 103 | for _, o := range out { 104 | sourceURL := "" 105 | sourceContent := sourceContentIdMap[o.Description] 106 | for _, source := range sources { 107 | if source.PageContent == sourceContent { 108 | sourceURL = source.Metadata["URL"].(string) 109 | break 110 | } 111 | } 112 | 113 | if !strings.Contains(textWithSources, o.Name) { 114 | slog.Warn("quote not found in text", "quote", o.Name) 115 | failed++ 116 | continue 117 | } 118 | matches++ 119 | 120 | textWithSources = strings.Replace(textWithSources, 121 | o.Name, 122 | fmt.Sprintf("%s [source](%s)", o.Name, sourceURL), 123 | 1, 124 | ) 125 | 126 | } 127 | slog.Info("Sources mapped", "amount", matches, "failed", failed) 128 | slog.Info("added sources", "text", textWithSources) 129 | slog.Info("Time taken to run the chain", "time", time.Since(startTime)) 130 | return textWithSources, nil 131 | } 132 | -------------------------------------------------------------------------------- /backend/lschains/ollama_functioncall.go: -------------------------------------------------------------------------------- 1 | package lschains 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "log" 8 | "log/slog" 9 | "slices" 10 | 11 | "github.com/tmc/langchaingo/llms" 12 | "github.com/tmc/langchaingo/llms/ollama" 13 | ) 14 | 15 | func RunFunctioncall() { 16 | llm, err := ollama.New( 17 | // ollama.WithModel("llama3:8b-instruct-q6_K"), 18 | ollama.WithModel("adrienbrault/nous-hermes2pro:Q6_K"), 19 | ollama.WithServerURL("http://gpu-ubuntu:11434"), 20 | ollama.WithRunnerNumCtx(8*1024), 21 | ollama.WithFormat("json"), 22 | ) 23 | if err != nil { 24 | log.Fatal(err) 25 | } 26 | 27 | var msgs []llms.MessageContent 28 | 29 | // system message defines the available tools. 30 | msgs = append(msgs, llms.TextParts(llms.ChatMessageTypeSystem, 31 | systemMessage())) 32 | msgs = append(msgs, llms.TextParts(llms.ChatMessageTypeHuman, 33 | "What's the weather like in Beijing? (both celsius and fahrenheit)")) 34 | 35 | ctx := context.Background() 36 | 37 | for { 38 | resp, err := llm.GenerateContent(ctx, msgs) 39 | if err != nil { 40 | log.Fatal(err) 41 | } 42 | slog.Info("Response", "resp", fmt.Sprintf("%+v", resp.Choices[0])) 43 | 44 | choice1 := resp.Choices[0] 45 | msgs = append(msgs, llms.TextParts(llms.ChatMessageTypeAI, choice1.Content)) 46 | 47 | if c := unmarshalCall(choice1.Content); c != nil { 48 | log.Printf("Call: %v", c.Tool) 49 | 50 | msg, cont := dispatchCall(c) 51 | if !cont { 52 | break 53 | } 54 | 55 | msgs = append(msgs, msg) 56 | } else { 57 | // Ollama doesn't always respond with a function call, let it try again. 58 | log.Printf("Not a call: %v", choice1.Content) 59 | 60 | msgs = append(msgs, llms.TextParts(llms.ChatMessageTypeHuman, "Sorry, I don't understand. Please try again.")) 61 | } 62 | } 63 | } 64 | 65 | type Call struct { 66 | Tool string `json:"tool"` 67 | Input map[string]any `json:"tool_input"` 68 | } 69 | 70 | func unmarshalCall(input string) *Call { 71 | var c Call 72 | 73 | if err := json.Unmarshal([]byte(input), &c); err == nil && c.Tool != "" { 74 | return &c 75 | } 76 | 77 | return nil 78 | } 79 | 80 | func dispatchCall(c *Call) (llms.MessageContent, bool) { 81 | // ollama doesn't always respond with a *valid* function call. As we're using prompt 82 | // engineering to inject the tools, it may hallucinate. 83 | if !validTool(c.Tool) { 84 | log.Printf("invalid function call: %#v", c) 85 | 86 | return llms.TextParts(llms.ChatMessageTypeHuman, 87 | "Tool does not exist, please try again."), true 88 | } 89 | 90 | // we could make this more dynamic, by parsing the function schema. 91 | switch c.Tool { 92 | case "getCurrentWeather": 93 | loc, ok := c.Input["location"].(string) 94 | if !ok { 95 | log.Fatal("invalid input") 96 | } 97 | unit, ok := c.Input["unit"].(string) 98 | if !ok { 99 | log.Fatal("invalid input") 100 | } 101 | 102 | weather, err := getCurrentWeather(loc, unit) 103 | if err != nil { 104 | log.Fatal(err) 105 | } 106 | return llms.TextParts(llms.ChatMessageTypeSystem, weather), true 107 | case "finalResponse": 108 | resp, ok := c.Input["response"].(string) 109 | if !ok { 110 | log.Fatal("invalid input") 111 | } 112 | 113 | log.Printf("Final response: %v", resp) 114 | 115 | return llms.MessageContent{}, false 116 | default: 117 | // we already checked above if we had a valid tool. 118 | panic("unreachable") 119 | } 120 | } 121 | 122 | func validTool(name string) bool { 123 | var valid []string 124 | 125 | for _, v := range functions { 126 | valid = append(valid, v.Name) 127 | } 128 | 129 | return slices.Contains(valid, name) 130 | } 131 | 132 | func systemMessage() string { 133 | bs, err := json.Marshal(functions) 134 | if err != nil { 135 | log.Fatal(err) 136 | } 137 | 138 | return fmt.Sprintf(`You have access to the following tools: 139 | 140 | %s 141 | 142 | To use a tool, respond with a JSON object with the following structure: 143 | { 144 | "tool": , 145 | "tool_input": 146 | } 147 | `, string(bs)) 148 | } 149 | 150 | func getCurrentWeather(location string, unit string) (string, error) { 151 | weatherInfo := map[string]any{ 152 | "location": location, 153 | "temperature": "6", 154 | "unit": unit, 155 | "forecast": []string{"sunny", "windy"}, 156 | } 157 | if unit == "fahrenheit" { 158 | weatherInfo["temperature"] = 43 159 | } 160 | 161 | b, err := json.Marshal(weatherInfo) 162 | if err != nil { 163 | return "", err 164 | } 165 | return string(b), nil 166 | } 167 | 168 | var functions = []llms.FunctionDefinition{ 169 | { 170 | Name: "getCurrentWeather", 171 | Description: "Get the current weather in a given location", 172 | Parameters: json.RawMessage(`{ 173 | "type": "object", 174 | "properties": { 175 | "location": {"type": "string", "description": "The city and state, e.g. San Francisco, CA"}, 176 | "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]} 177 | }, 178 | "required": ["location", "unit"] 179 | }`), 180 | }, 181 | { 182 | // I found that providing a tool for Ollama to give the final response significantly 183 | // increases the chances of success. 184 | Name: "finalResponse", 185 | Description: "Provide the final response to the user query", 186 | Parameters: json.RawMessage(`{ 187 | "type": "object", 188 | "properties": { 189 | "response": {"type": "string", "description": "The final response to the user query"} 190 | }, 191 | "required": ["response"] 192 | }`), 193 | }, 194 | } 195 | -------------------------------------------------------------------------------- /backend/lschains/source_chain_example.go: -------------------------------------------------------------------------------- 1 | package lschains 2 | 3 | import ( 4 | "github.com/tmc/langchaingo/llms/ollama" 5 | "github.com/tmc/langchaingo/schema" 6 | ) 7 | 8 | func RunSourceChainExample() (string, error) { 9 | sources := []schema.Document{ 10 | { 11 | PageContent: `Culture Store Forums Settings Front page layout Grid List Site theme light dark Sign in Skynet deferred — OpenAI opens the door for military uses but maintains AI weapons ban Despite new Pentagon collab, OpenAI wont allow customers to develop or use weapons with its tools. Benj Edwards - Jan 17, 2024 9:25 pm UTC EnlargeOpenAI / Getty Images / Benj Edwards reader comments 62 On Tuesday, ChatGPT developer OpenAI revealed that it is collaborating with the United States Defense Department on cybersecurity projects and exploring ways to prevent veteran suicide, reports Bloomberg. OpenAI revealed the collaboration during an interview with the news outlet at the World Economic Forum in Davos. The AI company recently modified its policies, allowing for certain military applications of its technology, while maintaining prohibitions against using it to develop weapons. According to Anna Makanju, OpenAIs vice president of global affairs, many people thought that [a previous blanket prohibition on military applications] would prohibit many of these use cases, which people think are very much aligned with what we want to see in the world.`, 12 | Metadata: map[string]any{ 13 | "URL": `https://arstechnica.com/information-technology/2024/01/openai-reveals-partnership-with-pentagon-on-cybersecurity-suicide-prevention/`, 14 | }, 15 | }, 16 | { 17 | PageContent: `Though, as OpenAI representative Niko Felix explained, there is still a blanket prohibition on developing and using weapons — you can see that it was originally and separately listed from “military and warfare.” After all, the military does more than make weapons, and weapons are made by others than the military. And it is precisely where those categories do not overlap that I would speculate OpenAI is examining new business opportunities. Not everything the Defense Department does is strictly warfare-related; as any academic, engineer or politician knows, the military establishment is deeply involved in all kinds of basic research, investment, small business funds and infrastructure support. OpenAI’s GPT platforms could be of great use to, say, army engineers looking to summarize decades of documentation of a region’s water infrastructure. It’s a genuine conundrum at many companies how to define and navigate their relationship with government and military money. Google’s “Project Maven” famously took one step too far, though few seemed to be as bothered by the multibillion-dollar JEDI cloud contract. It might be OK for an academic researcher on an Air Force Research lab grant to use GPT-4, but not a researcher inside the AFRL working on the same project. Where do you draw the line? Even a strict “no military” policy has to stop after a few removes. That said, the total removal of “military and warfare” from OpenAI’s prohibited uses suggests that the company is, at the very least, open`, 18 | Metadata: map[string]any{ 19 | "URL": `https://techcrunch.com/2024/01/12/openai-changes-policy-to-allow-military-applications/`, 20 | }, 21 | }, 22 | { 23 | PageContent: `Collab OpenAI Lifts Military Ban, Opens Doors to DOD for Cybersecurity Collab Charles Lyons-BurtJanuary 22, 2024Artificial Intelligence,Cybersecurity,News-comment At the World Economic Forum in Davos, Switzerland on Jan. 16, it was revealed that OpenAI and the Department of Defense will be collaborating on artificial intelligence-based cybersecurity technology. The news has broader implications than just those in the cyber or AI realms: before last week, OpenAI had resisted sanctioning use of its popular ChatGPT application by the defense industry and military. However, just days prior to the WEF, this policy was altered, reported The Register. The Potomac Officers Club will host its 2024 Cyber Summit on June 6. Register here (at the Early Bird rate!) to save a spot at what’s sure to be an essential gathering on all things cyber and government contracting, discussing the biggest issues of the day—such as the partnership between the Pentagon and OpenAI. Using ChatGPT for “military and warfare” operations was explicitly barred prior to last week, but their restriction no longer appears in the app’s permissions statement. An OpenAI representative said that the expansion is due to the recognition of “national security use cases that align with our mission.” According to OpenAI Vice President of Global Affairs Anna Makanju, the DOD collaboration intends to produce open-source cybersecurity software. But the company cautioned that its product`, 24 | Metadata: map[string]any{ 25 | "URL": `https://www.govconwire.com/2024/01/openai-lifts-military-ban-opens-doors-to-dod-for-cybersecurity-collab/`, 26 | }, 27 | }, 28 | { 29 | PageContent: `Contribute Become a Wordsmith Share your voice, and win big in blogathons Become a Mentor Craft careers by sharing your knowledge Become a Speaker Inspire minds, share your expertise Become an Instructor Shape next-gen innovators through our programs Corporate Our Offerings Build a data-powered and data-driven workforce Trainings Bridge your teams data skills with targeted training Analytics maturity Unleash the power of analytics for smarter outcomes Data Culture Break down barriers and democratize data access and usage Login Logout d : h : m : s Home Artificial Intelligence OpenAI Works with U.S. Military Soon After Policy Update OpenAI Works with U.S. Military Soon After Policy Update K K. C. Sabreena Basheer 18 Jan, 2024 • 2 min read Aligning with a recent policy shift, OpenAI, the creator of ChatGPT, is actively engaging with the U.S. military on various projects, notably focusing on cybersecurity capabilities. This development comes on the heels of OpenAI’s recent removal of language in its terms of service that previously restricted the use of its artificial intelligence (AI) in military applications. While the company maintains a ban on weapon development and harm, this collaboration underscores a broader update in policies to adapt to new applications of their technology. Also Read: Open`, 30 | Metadata: map[string]any{ 31 | "URL": `https://www.analyticsvidhya.com/blog/2024/01/openai-works-with-u-s-military-soon-after-policy-update`, 32 | }, 33 | }, 34 | } 35 | llm, err := ollama.New( 36 | ollama.WithModel("llama3:8b-instruct-q6_K"), 37 | ollama.WithServerURL("http://gpu-ubuntu:11434"), 38 | ollama.WithRunnerNumCtx(8*1024), 39 | ) 40 | if err != nil { 41 | return "", err 42 | } 43 | text := `OpenAI is collaborating with the United States Defense Department on cybersecurity projects and exploring ways to prevent veteran suicide. This collaboration was revealed during an interview at the World Economic Forum in Davos. OpenAI recently modified its policies, allowing for certain military applications of its technology while maintaining prohibitions against using it to develop weapons. According to Anna Makanju, OpenAIs vice president of global affairs, many people thought that a previous blanket prohibition on military applications would prohibit many use cases that are aligned with what people want to see in the world. 44 | 45 | Opens Doors to DOD for Cybersecurity Collab` 46 | return RunSourceChain(llm, sources, text) 47 | } 48 | -------------------------------------------------------------------------------- /backend/lschains/test_chain.go: -------------------------------------------------------------------------------- 1 | package lschains 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "log/slog" 7 | "strings" 8 | 9 | "github.com/tmc/langchaingo/agents" 10 | "github.com/tmc/langchaingo/callbacks" 11 | "github.com/tmc/langchaingo/chains" 12 | "github.com/tmc/langchaingo/llms/ollama" 13 | "github.com/tmc/langchaingo/memory" 14 | "github.com/tmc/langchaingo/tools" 15 | ) 16 | 17 | func RunTestChain() { 18 | slog.Info("Running test chain") 19 | llm, err := ollama.New( 20 | ollama.WithModel("llama3:8b-instruct-q6_K"), 21 | // ollama.WithModel("adrienbrault/nous-hermes2pro:Q6_K"), 22 | ollama.WithServerURL("http://gpu-ubuntu:11434"), 23 | ollama.WithRunnerNumCtx(8*1024), 24 | ) 25 | if err != nil { 26 | return 27 | } 28 | 29 | llmTools := []tools.Tool{tools.Calculator{}} 30 | 31 | executor := agents.NewExecutor( 32 | agents.NewConversationalAgent(llm, llmTools), 33 | llmTools, 34 | agents.WithParserErrorHandler(agents.NewParserErrorHandler( 35 | func(s string) string { 36 | return s 37 | }, 38 | )), 39 | agents.WithCallbacksHandler(callbacks.StreamLogHandler{}), 40 | agents.WithMemory(memory.NewConversationBuffer()), 41 | ) 42 | ans1, err := chains.Run(context.Background(), executor, "Hi! my name is Bob and the year I was born is 1987.", 43 | chains.WithTemperature(0.0)) 44 | if err != nil { 45 | log.Fatal(err) 46 | } 47 | slog.Info("Answer 1", "Answer", ans1) 48 | 49 | ans2, err := chains.Run(context.Background(), executor, "What is the year I was born times 34. Use tools. Only answer with the number, nothing else.") 50 | if err != nil { 51 | slog.Error("Answer 2", "Error", err) 52 | return 53 | } 54 | expectedRe := "67558" 55 | if !strings.Contains(ans2, expectedRe) && !strings.Contains(ans2, "67,558") { 56 | slog.Error("Answer 2", "Answer", ans2, "Expected", expectedRe) 57 | return 58 | } 59 | slog.Info("Answer 2", "Answer", ans2, "Expected", expectedRe) 60 | } 61 | -------------------------------------------------------------------------------- /backend/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | "os" 7 | 8 | "github.com/lmittmann/tint" 9 | "github.com/nilsherzig/LLocalSearch/utils" 10 | "github.com/tmc/langchaingo/memory" 11 | ) 12 | 13 | type Session struct { 14 | Title string 15 | Buffer *memory.ConversationWindowBuffer 16 | Elements []utils.HttpJsonStreamElement 17 | } 18 | 19 | type Sessions map[string]Session 20 | 21 | var sessions Sessions = make(Sessions) 22 | 23 | func main() { 24 | w := os.Stderr 25 | slog.SetDefault(slog.New( 26 | tint.NewHandler(w, &tint.Options{ 27 | AddSource: true, 28 | Level: slog.LevelDebug, 29 | }), 30 | )) 31 | 32 | SetupTutorialChatHistory() 33 | slog.Info("created example session") 34 | 35 | // lschains.RunSourceChainExample() 36 | slog.Info("Starting the server") 37 | StartApiServer() 38 | } 39 | 40 | func SetupTutorialChatHistory() { 41 | newFakeSession := Session{ 42 | Title: "LLocalSearch Tutorial", 43 | Buffer: memory.NewConversationWindowBuffer(1024 * 8), 44 | } 45 | 46 | userQuestion := "How does LLocalSearch work?" 47 | newFakeSession.Buffer.ChatHistory.AddUserMessage(context.Background(), userQuestion) 48 | 49 | tutorialMessageOne := `## Welcome to the LLocalSearch tutorial. 50 | Still working on this haha 👷 51 | 52 | Just ask a question. LLocalSearch will decide how complex the question is and will try to answer it. 53 | - 🌍 The question is easy and only the search result preview texts are used to answer it 54 | - 👀 The question requires a bit more context and the full search results texts are used to answer it 55 | - 📁 Already scraped data is used to answer the question 56 | 57 | You can start asking in this chat or open a new one. 🚀 58 | ` 59 | newFakeSession.Buffer.ChatHistory.AddAIMessage(context.Background(), tutorialMessageOne) 60 | 61 | newFakeSession.Elements = []utils.HttpJsonStreamElement{ 62 | { 63 | Message: "How does LLocalSearch work?", 64 | Close: false, 65 | Stream: false, 66 | StepType: utils.StepHandleUserMessage, 67 | }, 68 | { 69 | Message: tutorialMessageOne, 70 | Close: false, 71 | Stream: true, 72 | StepType: utils.StepHandleFinalAnswer, 73 | }, 74 | } 75 | sessions["tutorial"] = newFakeSession 76 | } 77 | -------------------------------------------------------------------------------- /backend/utils/customHandler.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "log/slog" 8 | "strings" 9 | "unicode/utf8" 10 | 11 | "github.com/tmc/langchaingo/llms" 12 | "github.com/tmc/langchaingo/schema" 13 | ) 14 | 15 | type CustomHandler struct { 16 | OutputChan chan<- HttpJsonStreamElement 17 | } 18 | 19 | func (l CustomHandler) HandleLLMGenerateContentStart(_ context.Context, ms []llms.MessageContent) { 20 | l.LogDebug("Entering LLM with messages:") 21 | for _, m := range ms { 22 | var buf strings.Builder 23 | for _, t := range m.Parts { 24 | if t, ok := t.(llms.TextContent); ok { 25 | buf.WriteString(t.Text) 26 | } 27 | } 28 | l.LogDebug(fmt.Sprintf("Role: %s", m.Role)) 29 | l.LogDebug(fmt.Sprintf("Text: %s", buf.String())) 30 | } 31 | } 32 | 33 | func (l CustomHandler) HandleLLMGenerateContentEnd(_ context.Context, res *llms.ContentResponse) { 34 | fmt.Println("Exiting LLM with response:") 35 | for _, c := range res.Choices { 36 | if c.Content != "" { 37 | l.LogDebug(fmt.Sprintf("Content: %s", c.Content)) 38 | } 39 | if c.StopReason != "" { 40 | l.LogDebug(fmt.Sprintf("StopReason: %s", c.StopReason)) 41 | } 42 | if len(c.GenerationInfo) > 0 { 43 | text := "" 44 | text += fmt.Sprintf("GenerationInfo: ") 45 | for k, v := range c.GenerationInfo { 46 | text += fmt.Sprintf("%20s: %v\n", k, v) 47 | } 48 | l.LogDebug(text) 49 | } 50 | if c.FuncCall != nil { 51 | l.LogDebug(fmt.Sprintf("FuncCall: %s %s", c.FuncCall.Name, c.FuncCall.Arguments)) 52 | } 53 | } 54 | } 55 | 56 | func (l CustomHandler) LogDebug(text string) { 57 | // log.Println(text) 58 | l.OutputChan <- HttpJsonStreamElement{ 59 | Message: text, 60 | Stream: false, 61 | } 62 | } 63 | 64 | func (l CustomHandler) HandleStreamingFunc(_ context.Context, chunk []byte) { 65 | l.OutputChan <- HttpJsonStreamElement{ 66 | Message: string(chunk), 67 | Stream: true, 68 | StepType: StepHandleStreaming, 69 | } 70 | } 71 | 72 | func (l CustomHandler) HandleText(_ context.Context, text string) { 73 | l.OutputChan <- HttpJsonStreamElement{ 74 | Message: text, 75 | Stream: false, 76 | } 77 | } 78 | 79 | func (l CustomHandler) HandleLLMStart(_ context.Context, prompts []string) { 80 | l.OutputChan <- HttpJsonStreamElement{ 81 | Message: fmt.Sprintf("Entering LLM with prompts: %s", prompts), 82 | Stream: false, 83 | StepType: StepHandleLlmStart, 84 | } 85 | } 86 | 87 | func (l CustomHandler) HandleLLMError(_ context.Context, err error) { 88 | fmt.Println("Exiting LLM with error:", err) 89 | l.OutputChan <- HttpJsonStreamElement{ 90 | Message: err.Error(), 91 | Stream: false, 92 | } 93 | } 94 | 95 | func (l CustomHandler) HandleChainStart(_ context.Context, inputs map[string]any) { 96 | chainValuesJson, err := json.Marshal(inputs) 97 | if err != nil { 98 | fmt.Println("Error marshalling chain values:", err) 99 | } 100 | 101 | charCount := utf8.RuneCountInString(string(chainValuesJson)) 102 | slog.Info("Entering chain", "tokens", (charCount / 4)) 103 | 104 | l.OutputChan <- HttpJsonStreamElement{ 105 | Message: fmt.Sprintf("Entering chain with %d tokens: %s", (charCount / 4), chainValuesJson), 106 | Stream: false, 107 | StepType: StepHandleChainStart, 108 | } 109 | } 110 | 111 | func (l CustomHandler) HandleChainEnd(_ context.Context, outputs map[string]any) { 112 | chainValuesJson, err := json.Marshal(outputs) 113 | if err != nil { 114 | fmt.Println("Error marshalling chain values:", err) 115 | } 116 | l.OutputChan <- HttpJsonStreamElement{ 117 | Message: fmt.Sprintf("Exiting chain with outputs: %s", chainValuesJson), 118 | Stream: false, 119 | StepType: StepHandleChainEnd, 120 | } 121 | } 122 | 123 | func (l CustomHandler) HandleChainError(_ context.Context, err error) { 124 | message := fmt.Sprintf("Exiting chain with error: %v", err) 125 | fmt.Println(message) 126 | l.OutputChan <- HttpJsonStreamElement{ 127 | Message: message, 128 | Stream: false, 129 | StepType: StepHandleChainError, 130 | } 131 | } 132 | 133 | func (l CustomHandler) HandleToolStart(_ context.Context, input string) { 134 | l.OutputChan <- HttpJsonStreamElement{ 135 | Message: fmt.Sprintf("Entering tool with input: %s", removeNewLines(input)), 136 | Stream: false, 137 | StepType: StepHandleToolStart, 138 | } 139 | } 140 | 141 | func (l CustomHandler) HandleToolEnd(_ context.Context, output string) { 142 | l.OutputChan <- HttpJsonStreamElement{ 143 | Message: fmt.Sprintf("Exiting tool with output: %s", removeNewLines(output)), 144 | Stream: false, 145 | StepType: StepHandleToolEnd, 146 | } 147 | } 148 | 149 | func (l CustomHandler) HandleToolError(_ context.Context, err error) { 150 | fmt.Println("Exiting tool with error:", err) 151 | l.OutputChan <- HttpJsonStreamElement{ 152 | Message: err.Error(), 153 | Stream: false, 154 | } 155 | } 156 | 157 | func (l CustomHandler) HandleAgentAction(_ context.Context, action schema.AgentAction) { 158 | actionJson, err := json.Marshal(action) 159 | if err != nil { 160 | fmt.Println("Error marshalling action:", err) 161 | } 162 | 163 | l.OutputChan <- HttpJsonStreamElement{ 164 | Message: string(actionJson), 165 | Stream: false, 166 | StepType: StepHandleAgentAction, 167 | } 168 | } 169 | 170 | func (l CustomHandler) HandleAgentFinish(_ context.Context, finish schema.AgentFinish) { 171 | finishJson, err := json.Marshal(finish) 172 | if err != nil { 173 | fmt.Println("Error marshalling finish:", err) 174 | } 175 | l.OutputChan <- HttpJsonStreamElement{ 176 | Message: string(finishJson), 177 | Stream: false, 178 | StepType: StepHandleAgentFinish, 179 | } 180 | } 181 | 182 | func (l CustomHandler) HandleRetrieverStart(_ context.Context, query string) { 183 | fmt.Println("Entering retriever with query:", removeNewLines(query)) 184 | } 185 | 186 | func (l CustomHandler) HandleRetrieverEnd(_ context.Context, query string, documents []schema.Document) { 187 | // fmt.Println("Exiting retriever with documents for query:", documents, query) 188 | l.OutputChan <- HttpJsonStreamElement{ 189 | Message: fmt.Sprintf("Exiting retriever with documents for query: %s", query), 190 | Stream: false, 191 | StepType: StepHandleRetriverEnd, 192 | } 193 | } 194 | 195 | func (l CustomHandler) HandleVectorFound(_ context.Context, vectorString string) { 196 | l.OutputChan <- HttpJsonStreamElement{ 197 | Message: fmt.Sprintf("Found vector %s", vectorString), 198 | Stream: false, 199 | StepType: StepHandleVectorFound, 200 | } 201 | } 202 | 203 | func (l CustomHandler) HandleSourceAdded(_ context.Context, source Source) { 204 | l.OutputChan <- HttpJsonStreamElement{ 205 | Message: "Source added", 206 | Source: source, 207 | Stream: false, 208 | StepType: StepHandleSourceAdded, 209 | } 210 | } 211 | 212 | func formatChainValues(values map[string]any) string { 213 | output := "" 214 | for key, value := range values { 215 | output += fmt.Sprintf("\"%s\" : \"%s\", ", removeNewLines(key), removeNewLines(value)) 216 | } 217 | 218 | return output 219 | } 220 | 221 | func formatAgentAction(action schema.AgentAction) string { 222 | return fmt.Sprintf("\"%s\" with input \"%s\"", removeNewLines(action.Tool), removeNewLines(action.ToolInput)) 223 | } 224 | 225 | func removeNewLines(s any) string { 226 | return strings.ReplaceAll(fmt.Sprint(s), "\n", " ") 227 | } 228 | -------------------------------------------------------------------------------- /backend/utils/helper.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | ) 7 | 8 | func DownloadWebsiteText(url string) (string, error) { 9 | // Make an HTTP GET request to the URL 10 | resp, err := http.Get(url) 11 | if err != nil { 12 | return "", err // Return the error if the request failed 13 | } 14 | defer resp.Body.Close() 15 | 16 | // Read the response body 17 | body, err := io.ReadAll(resp.Body) 18 | if err != nil { 19 | return "", err // Return the error if reading the body failed 20 | } 21 | 22 | // Convert the body to a string and return it 23 | return string(body), nil 24 | } 25 | -------------------------------------------------------------------------------- /backend/utils/llm_backends.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | "os" 8 | 9 | "github.com/google/uuid" 10 | "github.com/ollama/ollama/api" 11 | // "github.com/tmc/langchaingo/httputil" 12 | "github.com/tmc/langchaingo/llms/ollama" 13 | ) 14 | 15 | var EmbeddingsModel = os.Getenv("EMBEDDINGS_MODEL_NAME") 16 | 17 | func NewOllamaEmbeddingLLM() (*ollama.LLM, error) { 18 | modelName := EmbeddingsModel 19 | return NewOllama(modelName, (1024 * 8)) 20 | } 21 | 22 | func NewOllama(modelName string, contextSize int) (*ollama.LLM, error) { 23 | return ollama.New(ollama.WithModel(modelName), 24 | ollama.WithServerURL(os.Getenv("OLLAMA_HOST")), 25 | ollama.WithRunnerNumCtx(contextSize), 26 | // ollama.WithHTTPClient(httputil.DebugHTTPClient), 27 | ) 28 | } 29 | 30 | func GetSessionString() string { 31 | return uuid.New().String() 32 | } 33 | 34 | func CheckIfModelExistsOrPull(modelName string) error { 35 | if err := CheckIfModelExists(modelName); err != nil { 36 | slog.Warn("Model does not exist, pulling it", "model", modelName) 37 | if err := OllamaPullModel(modelName); err != nil { 38 | return err 39 | } 40 | } 41 | return nil 42 | } 43 | 44 | func GetOllamaModelList() ([]string, error) { 45 | client, err := api.ClientFromEnvironment() 46 | if err != nil { 47 | return nil, err 48 | } 49 | models, err := client.List(context.Background()) 50 | if err != nil { 51 | return nil, err 52 | } 53 | modelNames := make([]string, 0) 54 | for _, model := range models.Models { 55 | modelNames = append(modelNames, model.Name) 56 | } 57 | return modelNames, nil 58 | } 59 | 60 | func CheckIfModelExists(requestName string) error { 61 | modelNames, err := GetOllamaModelList() 62 | if err != nil { 63 | return err 64 | } 65 | for _, mn := range modelNames { 66 | if requestName == mn { 67 | return nil 68 | } 69 | } 70 | return fmt.Errorf("Model %s does not exist", requestName) 71 | } 72 | 73 | func OllamaPullModel(modelName string) error { 74 | pullReq := api.PullRequest{ 75 | Model: modelName, 76 | Insecure: false, 77 | Name: modelName, 78 | } 79 | client, err := api.ClientFromEnvironment() 80 | if err != nil { 81 | return err 82 | } 83 | return client.Pull(context.Background(), &pullReq, pullProgressHandler) 84 | } 85 | 86 | var lastProgress string 87 | 88 | func pullProgressHandler(progress api.ProgressResponse) error { 89 | percentage := progressPercentage(progress) 90 | if percentage != lastProgress { 91 | slog.Info("Pulling model", "progress", percentage) 92 | lastProgress = percentage 93 | } 94 | return nil 95 | } 96 | 97 | func progressPercentage(progress api.ProgressResponse) string { 98 | return fmt.Sprintf("%d", (progress.Completed*100)/(progress.Total+1)) 99 | } 100 | -------------------------------------------------------------------------------- /backend/utils/load_localfiles.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "log/slog" 8 | "os" 9 | "path/filepath" 10 | ) 11 | 12 | // visitFile is the callback function called for each file or directory found recursively 13 | 14 | // readFilesRecursively reads all files in a directory recursively 15 | func readFilesRecursively(rootDir string, sessionString string, chunkSize int, chunkOverlap int) error { 16 | visitFile := func(path string, fi os.FileInfo, err error) error { 17 | if err != nil { 18 | return err 19 | } 20 | if !fi.IsDir() { 21 | if filepath.Ext(path) != ".md" { 22 | return nil 23 | } 24 | file, err := os.Open(path) 25 | if err != nil { 26 | slog.Error("Error opening file", "path", path, "error", err) 27 | return err 28 | } 29 | defer file.Close() 30 | text, err := io.ReadAll(file) 31 | if err != nil { 32 | slog.Error("Error reading file", "path", path, "error", err) 33 | return err 34 | } 35 | 36 | LoadMarkdownToVectorDB(context.Background(), string(text), sessionString, chunkSize, chunkOverlap, path) 37 | slog.Info("Loaded file", "path", path) 38 | } 39 | return nil 40 | } 41 | return filepath.Walk(rootDir, visitFile) 42 | } 43 | 44 | func LoadLocalFiles(sessionString string, chunkSize int, chunkOverlap int) { 45 | rootDir := "/localfiles" // Replace with the path to your directory 46 | if err := readFilesRecursively(rootDir, sessionString, chunkSize, chunkOverlap); err != nil { 47 | fmt.Println("Error:", err) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /backend/utils/prompts.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | func ParsingErrorPrompt() string { 4 | return "Parsing Error: Check your output and make sure it conforms to the format." 5 | } 6 | -------------------------------------------------------------------------------- /backend/utils/types.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | type ChatListItem struct { 4 | SessionId string `json:"sessionid"` 5 | Title string `json:"title"` 6 | } 7 | 8 | type ChatHistoryItem struct { 9 | Element HttpJsonStreamElement `json:"element"` 10 | } 11 | 12 | type HttpError struct { 13 | Error string `json:"error"` 14 | } 15 | 16 | type HttpJsonStreamElement struct { 17 | Message string `json:"message"` 18 | Close bool `json:"close"` 19 | Stream bool `json:"stream"` 20 | StepType StepType `json:"stepType"` 21 | Source Source `json:"source"` 22 | Session string `json:"session"` 23 | TimeStamp int64 `json:"timeStamp"` 24 | } 25 | 26 | type ClientSettings struct { 27 | ContextSize int `json:"contextSize"` 28 | MaxIterations int `json:"maxIterations"` 29 | ModelName string `json:"modelName"` 30 | Prompt string `json:"prompt"` 31 | Session string `json:"session"` 32 | Temperature float64 `json:"temperature"` 33 | ToolNames []string `json:"toolNames"` 34 | WebSearchCategories []string `json:"webSearchCategories"` 35 | AmountOfResults int `json:"amountOfResults"` 36 | MinResultScore float64 `json:"minResultScore"` 37 | AmountOfWebsites int `json:"amountOfWebsites"` 38 | ChunkSize int `json:"chunkSize"` 39 | ChunkOverlap int `json:"chunkOverlap"` 40 | SystemMessage string `json:"systemMessage"` 41 | } 42 | 43 | type StepType string 44 | 45 | const ( 46 | StepHandleAgentAction StepType = "HandleAgentAction" 47 | StepHandleAgentFinish StepType = "HandleAgentFinish" 48 | StepHandleChainEnd StepType = "HandleChainEnd" 49 | StepHandleChainError StepType = "HandleChainError" 50 | StepHandleChainStart StepType = "HandleChainStart" 51 | StepHandleFinalAnswer StepType = "HandleFinalAnswer" 52 | StepHandleLLMGenerateContentEnd StepType = "HandleLLMGenerateContentEnd" 53 | StepHandleLLMGenerateContentStart StepType = "HandleLLMGenerateContentStart" 54 | StepHandleLlmEnd StepType = "HandleLlmEnd" 55 | StepHandleLlmError StepType = "HandleLlmError" 56 | StepHandleLlmStart StepType = "HandleLlmStart" 57 | StepHandleNewSession StepType = "HandleNewSession" 58 | StepHandleOllamaStart StepType = "HandleOllamaStart" 59 | StepHandleParseError StepType = "HandleParseError" 60 | StepHandleRetriverEnd StepType = "HandleRetriverEnd" 61 | StepHandleRetriverStart StepType = "HandleRetriverStart" 62 | StepHandleSourceAdded StepType = "HandleSourceAdded" 63 | StepHandleToolEnd StepType = "HandleToolEnd" 64 | StepHandleToolError StepType = "HandleToolError" 65 | StepHandleToolStart StepType = "HandleToolStart" 66 | StepHandleVectorFound StepType = "HandleVectorFound" 67 | StepHandleFormat StepType = "HandleFormat" 68 | StepHandleStreaming StepType = "HandleStreaming" 69 | StepHandleUserMessage StepType = "HandleUserMessage" 70 | ) 71 | 72 | type Source struct { 73 | Name string `json:"name"` 74 | Link string `json:"link"` 75 | Summary string `json:"summary"` 76 | Engine string `json:"engine"` 77 | Title string `json:"title"` 78 | } 79 | 80 | type SeaXngResult struct { 81 | Query string `json:"query"` 82 | NumberOfResults int `json:"number_of_results"` 83 | Results []struct { 84 | URL string `json:"url"` 85 | Title string `json:"title"` 86 | Content string `json:"content"` 87 | PublishedDate any `json:"publishedDate,omitempty"` 88 | ImgSrc any `json:"img_src,omitempty"` 89 | Engine string `json:"engine"` 90 | ParsedURL []string `json:"parsed_url"` 91 | Template string `json:"template"` 92 | Engines []string `json:"engines"` 93 | Positions []int `json:"positions"` 94 | Score float64 `json:"score"` 95 | Category string `json:"category"` 96 | } `json:"results"` 97 | Answers []any `json:"answers"` 98 | Corrections []any `json:"corrections"` 99 | Infoboxes []any `json:"infoboxes"` 100 | Suggestions []string `json:"suggestions"` 101 | UnresponsiveEngines []any `json:"unresponsive_engines"` 102 | } 103 | -------------------------------------------------------------------------------- /backend/utils/vector_db_handler.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | "os" 8 | "regexp" 9 | "strings" 10 | 11 | "github.com/microcosm-cc/bluemonday" 12 | 13 | "github.com/tmc/langchaingo/documentloaders" 14 | "github.com/tmc/langchaingo/embeddings" 15 | "github.com/tmc/langchaingo/schema" 16 | "github.com/tmc/langchaingo/textsplitter" 17 | "github.com/tmc/langchaingo/vectorstores/chroma" 18 | ) 19 | 20 | var spaceRegex = regexp.MustCompile(`\s+`) 21 | 22 | func saveToVectorDb(timeoutCtx context.Context, docs []schema.Document, sessionString string) error { 23 | llm, err := NewOllamaEmbeddingLLM() 24 | if err != nil { 25 | return err 26 | } 27 | 28 | embeder, err := embeddings.NewEmbedder(llm) 29 | if err != nil { 30 | return err 31 | } 32 | 33 | store, errNs := chroma.New( 34 | chroma.WithChromaURL(os.Getenv("CHROMA_DB_URL")), 35 | chroma.WithEmbedder(embeder), 36 | chroma.WithDistanceFunction("cosine"), 37 | chroma.WithNameSpace(sessionString), 38 | ) 39 | 40 | if errNs != nil { 41 | return errNs 42 | } 43 | 44 | type meta = map[string]any 45 | for i := range docs { 46 | if len(docs[i].PageContent) == 0 { 47 | // remove the document from the list 48 | docs = append(docs[:i], docs[i+1:]...) 49 | } 50 | } 51 | 52 | _, errAd := store.AddDocuments(timeoutCtx, docs) 53 | 54 | if errAd != nil { 55 | slog.Warn("Error adding document", "error", errAd) 56 | return fmt.Errorf("Error adding document: %v\n", errAd) 57 | } 58 | 59 | // log.Printf("Added %d documents\n", len(res)) 60 | return nil 61 | } 62 | 63 | func LoadMarkdownToVectorDB(ctx context.Context, markdown string, sessionString string, chunkSize int, chunkOverlap int, path string) error { 64 | vectorLoader := documentloaders.NewText(strings.NewReader(markdown)) 65 | splitter := textsplitter.NewTokenSplitter( 66 | textsplitter.WithSeparators([]string{"\n\n", "\n"}), 67 | ) 68 | splitter.ChunkSize = chunkSize 69 | splitter.ChunkOverlap = chunkOverlap 70 | docs, err := vectorLoader.LoadAndSplit(ctx, splitter) 71 | 72 | for i := range docs { 73 | docs[i].Metadata = map[string]interface{}{ 74 | "URL": path, 75 | } 76 | } 77 | 78 | err = saveToVectorDb(context.Background(), docs, sessionString) 79 | if err != nil { 80 | return err 81 | } 82 | return nil 83 | } 84 | 85 | func DownloadWebsiteToVectorDB(ctx context.Context, url string, sessionString string, chunkSize int, chunkOverlap int) error { 86 | // log.Printf("downloading: %s", url) 87 | html, err := DownloadWebsiteText(url) 88 | if err != nil { 89 | fmt.Printf("error from evaluator: %s", err.Error()) 90 | return err 91 | } 92 | 93 | sanitizedHtml := stripHtml(html) 94 | if len(sanitizedHtml) == 0 { 95 | return fmt.Errorf("no content found") 96 | } 97 | 98 | vectorLoader := documentloaders.NewText(strings.NewReader(sanitizedHtml)) 99 | splitter := textsplitter.NewTokenSplitter( 100 | textsplitter.WithSeparators([]string{"\n\n", "\n"}), 101 | ) 102 | splitter.ChunkSize = chunkSize 103 | splitter.ChunkOverlap = chunkOverlap 104 | docs, err := vectorLoader.LoadAndSplit(ctx, splitter) 105 | 106 | for i := range docs { 107 | docs[i].Metadata = map[string]interface{}{ 108 | "URL": url, 109 | } 110 | } 111 | 112 | // timeoutCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second) 113 | // defer cancel() 114 | 115 | err = saveToVectorDb(context.Background(), docs, sessionString) 116 | if err != nil { 117 | return err 118 | } 119 | return nil 120 | } 121 | 122 | func stripHtml(html string) string { 123 | policy := bluemonday.StrictPolicy() 124 | result := policy.Sanitize(html) 125 | result = strings.ReplaceAll(result, """, "") 126 | result = strings.ReplaceAll(result, "'", "") 127 | result = spaceRegex.ReplaceAllString(result, " ") 128 | result = strings.ReplaceAll(result, "\t", "") 129 | return result 130 | } 131 | -------------------------------------------------------------------------------- /custom-server.js: -------------------------------------------------------------------------------- 1 | import { handler } from './build/handler.js'; 2 | import express from 'express'; 3 | import { createProxyMiddleware } from 'http-proxy-middleware'; 4 | 5 | const app = express(); 6 | 7 | const apiProxyOptions = { 8 | target: 'http://backend:8080', // URL of your backend 9 | changeOrigin: true, 10 | pathRewrite: { 11 | '^/api': '' // rewrite path 12 | } 13 | }; 14 | 15 | // Apply proxy to /api 16 | app.use('/api', createProxyMiddleware(apiProxyOptions)); 17 | 18 | // let SvelteKit handle everything else, including serving prerendered pages and static assets 19 | app.use(handler); 20 | 21 | app.listen(3000, () => { 22 | console.log('frontend listening on port 3000'); 23 | }); 24 | -------------------------------------------------------------------------------- /docker-compose.dev.yaml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | services: 3 | backend: 4 | volumes: 5 | - ./backend/:/app/ 6 | - /home/nils/Notes/:/localfiles/:ro 7 | build: 8 | context: ./backend 9 | dockerfile: Dockerfile.dev 10 | environment: 11 | - OLLAMA_HOST=${OLLAMA_HOST:-host.docker.internal:11434} 12 | - CHROMA_DB_URL=${CHROMA_DB_URL:-http://chromadb:8000} 13 | - SEARXNG_DOMAIN=${SEARXNG_DOMAIN:-http://searxng:8080} 14 | - EMBEDDINGS_MODEL_NAME=${EMBEDDINGS_MODEL_NAME:-nomic-embed-text:v1.5} 15 | - VERSION=${VERSION:-dev} 16 | networks: 17 | - llm_network_dev 18 | extra_hosts: 19 | - 'host.docker.internal:host-gateway' 20 | 21 | frontend: 22 | depends_on: 23 | - backend 24 | build: 25 | context: . 26 | dockerfile: Dockerfile.dev 27 | environment: 28 | - PUBLIC_VERSION=dev 29 | volumes: 30 | - ./:/app/ 31 | ports: 32 | - '3000:5173' 33 | networks: 34 | - llm_network_dev 35 | 36 | chromadb: 37 | image: chromadb/chroma 38 | networks: 39 | - llm_network_dev 40 | # attach: false 41 | # logging: 42 | # driver: none 43 | 44 | redis: 45 | image: docker.io/library/redis:alpine 46 | command: redis-server --save 30 1 --loglevel warning 47 | networks: 48 | - searxng 49 | volumes: 50 | - redis-data:/data 51 | cap_drop: 52 | - ALL 53 | cap_add: 54 | - SETGID 55 | - SETUID 56 | - DAC_OVERRIDE 57 | 58 | searxng: 59 | image: docker.io/searxng/searxng:latest 60 | networks: 61 | - searxng 62 | - llm_network_dev 63 | volumes: 64 | - ./searxng:/etc/searxng:rw 65 | environment: 66 | - SEARXNG_BASE_URL=https://${SEARXNG_HOSTNAME:-localhost}/ 67 | cap_drop: 68 | - ALL 69 | cap_add: 70 | - CHOWN 71 | - SETGID 72 | - SETUID 73 | logging: 74 | driver: 'json-file' 75 | options: 76 | max-size: '1m' 77 | max-file: '1' 78 | 79 | networks: 80 | llm_network_dev: 81 | driver: bridge 82 | searxng: 83 | ipam: 84 | driver: default 85 | 86 | volumes: 87 | redis-data: 88 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | services: 3 | backend: 4 | image: nilsherzig/llocalsearch-backend:latest 5 | environment: 6 | - OLLAMA_HOST=${OLLAMA_HOST:-host.docker.internal:11434} 7 | - CHROMA_DB_URL=${CHROMA_DB_URL:-http://chromadb:8000} 8 | - SEARXNG_DOMAIN=${SEARXNG_DOMAIN:-http://searxng:8080} 9 | - EMBEDDINGS_MODEL_NAME=${EMBEDDINGS_MODEL_NAME:-nomic-embed-text:v1.5} 10 | networks: 11 | - llm_network 12 | extra_hosts: 13 | - 'host.docker.internal:host-gateway' 14 | 15 | frontend: 16 | depends_on: 17 | - backend 18 | image: nilsherzig/llocalsearch-frontend:latest 19 | ports: 20 | - '3000:80' 21 | networks: 22 | - llm_network 23 | 24 | chromadb: 25 | image: chromadb/chroma 26 | networks: 27 | - llm_network 28 | 29 | redis: 30 | image: docker.io/library/redis:alpine 31 | command: redis-server --save 30 1 --loglevel warning 32 | networks: 33 | - searxng 34 | volumes: 35 | - redis-data:/data 36 | cap_drop: 37 | - ALL 38 | cap_add: 39 | - SETGID 40 | - SETUID 41 | - DAC_OVERRIDE 42 | 43 | searxng: 44 | image: docker.io/searxng/searxng:latest 45 | networks: 46 | - searxng 47 | - llm_network 48 | volumes: 49 | - ./searxng:/etc/searxng:rw 50 | environment: 51 | - SEARXNG_BASE_URL=https://${SEARXNG_HOSTNAME:-localhost}/ 52 | cap_drop: 53 | - ALL 54 | cap_add: 55 | - CHOWN 56 | - SETGID 57 | - SETUID 58 | logging: 59 | driver: 'json-file' 60 | options: 61 | max-size: '1m' 62 | max-file: '1' 63 | 64 | networks: 65 | llm_network: 66 | driver: bridge 67 | searxng: 68 | ipam: 69 | driver: default 70 | 71 | volumes: 72 | redis-data: 73 | -------------------------------------------------------------------------------- /env-example: -------------------------------------------------------------------------------- 1 | # copy this file to .env to apply these environment variables 2 | # sensible defaults will be used if these are not set 3 | OLLAMA_HOST=http://192.168.0.109:11434 4 | MAX_ITERATIONS=30 5 | CHROMA_DB_URL=http://chromadb:8000 6 | SEARXNG_DOMAIN=http://searxng:8080 7 | SEARXNG_HOSTNAME=localhost 8 | -------------------------------------------------------------------------------- /go.work: -------------------------------------------------------------------------------- 1 | go 1.22.2 2 | 3 | use ./backend 4 | use ./metrics 5 | -------------------------------------------------------------------------------- /go.work.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go/vertexai v0.7.1/go.mod h1:HfnfYR9aPS+qF2436S6Hzuw0Fp+PORjzK3ggqymdzSU= 2 | github.com/Code-Hex/go-generics-cache v1.3.1/go.mod h1:qxcC9kRVrct9rHeiYpFWSoW1vxyillCVzX13KZG8dl4= 3 | github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= 4 | github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= 5 | github.com/antchfx/htmlquery v1.3.0/go.mod h1:zKPDVTMhfOmcwxheXUsx4rKJy8KEY/PU6eXr/2SebQ8= 6 | github.com/antchfx/xmlquery v1.3.17/go.mod h1:Afkq4JIeXut75taLSuI31ISJ/zeq+3jG7TunF7noreA= 7 | github.com/antchfx/xpath v1.2.4/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs= 8 | github.com/apache/arrow/go/arrow v0.0.0-20201229220542-30ce2eb5d4dc/go.mod h1:c9sxoIT3YgLxH4UhLOCKaBlEojuMhVYpk4Ntv3opUTQ= 9 | github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= 10 | github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= 11 | github.com/aws/aws-sdk-go-v2 v1.25.2/go.mod h1:Evoc5AsmtveRt1komDwIsjHFyrP5tDuF1D1U+6z6pNo= 12 | github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.1/go.mod h1:sxpLb+nZk7tIfCWChfd+h4QwHNUR57d8hA1cleTkjJo= 13 | github.com/aws/aws-sdk-go-v2/config v1.27.4/go.mod h1:zq2FFXK3A416kiukwpsd+rD4ny6JC7QSkp4QdN1Mp2g= 14 | github.com/aws/aws-sdk-go-v2/credentials v1.17.4/go.mod h1:+30tpwrkOgvkJL1rUZuRLoxcJwtI/OkeBLYnHxJtVe0= 15 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.15.2/go.mod h1:iRlGzMix0SExQEviAyptRWRGdYNo3+ufW/lCzvKVTUc= 16 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.2/go.mod h1:wRQv0nN6v9wDXuWThpovGQjqF1HFdcgWjporw14lS8k= 17 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.2/go.mod h1:tyF5sKccmDz0Bv4NrstEr+/9YkSPJHrcO7UsUKf7pWM= 18 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY= 19 | github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.7.1/go.mod h1:0S4p4IdEhakLLKoVwmI3vIoOtIt17TFo4QUFuez9O0Y= 20 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.1/go.mod h1:JKpmtYhhPs7D97NL/ltqz7yCkERFW5dOlHyVl66ZYF8= 21 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.2/go.mod h1:Ru7vg1iQ7cR4i7SZ/JTLYN9kaXtbL69UdgG0OQWQxW0= 22 | github.com/aws/aws-sdk-go-v2/service/sso v1.20.1/go.mod h1:RsYqzYr2F2oPDdpy+PdhephuZxTfjHQe7SOBcZGoAU8= 23 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.23.1/go.mod h1:YjAPFn4kGFqKC54VsHs5fn5B6d+PCY2tziEa3U/GB5Y= 24 | github.com/aws/aws-sdk-go-v2/service/sts v1.28.1/go.mod h1:uQ7YYKZt3adCRrdCBREm1CD3efFLOUNH77MrUCvx5oA= 25 | github.com/aws/smithy-go v1.20.1/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E= 26 | github.com/chewxy/hm v1.0.0/go.mod h1:qg9YI4q6Fkj/whwHR1D+bOGeF7SniIP40VweVepLjg0= 27 | github.com/chewxy/math32 v1.0.8/go.mod h1:dOB2rcuFrCn6UHrze36WSLVPKtzPMRAQvBvUwkSsLqs= 28 | github.com/cockroachdb/errors v1.9.1/go.mod h1:2sxOtL2WIc096WSZqZ5h8fa17rdDq9HZOZLBCor4mBk= 29 | github.com/cockroachdb/logtags v0.0.0-20211118104740-dabe8e521a4f/go.mod h1:Vz9DsVWQQhf3vs21MhPMZpMGSht7O/2vFW2xusFUVOs= 30 | github.com/cockroachdb/redact v1.1.3/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg= 31 | github.com/cohere-ai/tokenizer v1.1.2/go.mod h1:9MNFPd9j1fuiEK3ua2HSCUxxcrfGMlSqpa93livg/C0= 32 | github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= 33 | github.com/d4l3k/go-bfloat16 v0.0.0-20211005043715-690c3bdd05f1/go.mod h1:uw2gLcxEuYUlAd/EXyjc/v55nd3+47YAgWbSXVxPrNI= 34 | github.com/deepmap/oapi-codegen/v2 v2.1.0/go.mod h1:R1wL226vc5VmCNJUvMyYr3hJMm5reyv25j952zAVXZ8= 35 | github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= 36 | github.com/gage-technologies/mistral-go v1.0.0/go.mod h1:tF++Xt7U975GcLlzhrjSQb8l/x+PrriO9QEdsgm9l28= 37 | github.com/getsentry/sentry-go v0.12.0/go.mod h1:NSap0JBYWzHND8oMbyi0+XZhUalc1TBdRL1M71JZW2c= 38 | github.com/gin-contrib/cors v1.4.0/go.mod h1:bs9pNM0x/UsmHPBWT2xZz9ROh8xYjYkiURUfmBoMlcs= 39 | github.com/go-openapi/analysis v0.21.2/go.mod h1:HZwRk4RRisyG8vx2Oe6aqeSQcoxRp47Xkp3+K6q+LdY= 40 | github.com/go-openapi/errors v0.20.3/go.mod h1:Z3FlZ4I8jEGxjUK+bugx3on2mIAk4txuAOhlsB1FSgk= 41 | github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= 42 | github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns= 43 | github.com/go-openapi/loads v0.21.1/go.mod h1:/DtAMXXneXFjbQMGEtbamCZb+4x7eGwkvZCvBmwUG+g= 44 | github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I= 45 | github.com/go-openapi/strfmt v0.21.3/go.mod h1:k+RzNO0Da+k3FrrynSNN8F7n/peCmQQqbbXjtDfvmGg= 46 | github.com/go-openapi/swag v0.22.4/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= 47 | github.com/go-openapi/validate v0.21.0/go.mod h1:rjnrwK57VJ7A8xqfpAOEKRH8yQSGUriMu5/zuPSQ1hg= 48 | github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= 49 | github.com/gocolly/colly v1.2.0/go.mod h1:Hof5T3ZswNVsOHYmba1u03W65HDWgpV5HifSuueE0EA= 50 | github.com/google/flatbuffers v23.5.26+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= 51 | github.com/grpc-ecosystem/go-grpc-middleware v1.3.0/go.mod h1:z0ButlSOZa5vEBq9m2m2hlwIgKw+rp3sdCBRoJY+30Y= 52 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 53 | github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= 54 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= 55 | github.com/jackc/pgx/v5 v5.5.4/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= 56 | github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= 57 | github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 58 | github.com/kennygrant/sanitize v1.2.4/go.mod h1:LGsjYYtgxbetdg5owWB2mpgUL6e2nfw2eObZ0u0qvak= 59 | github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= 60 | github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 61 | github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= 62 | github.com/metaphorsystems/metaphor-go v0.0.0-20230816231421-43794c04824e/go.mod h1:mDz8kHE7x6Ja95drCQ2T1vLyPRc/t69Cf3wau91E3QU= 63 | github.com/milvus-io/milvus-proto/go-api/v2 v2.3.5/go.mod h1:1OIl0v5PQeNxIJhCvY+K55CBUOYDZevw9g9380u1Wek= 64 | github.com/milvus-io/milvus-sdk-go/v2 v2.3.6/go.mod h1:bYFSXVxEj6A/T8BfiR+xkofKbAVZpWiDvKr3SzYUWiA= 65 | github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 66 | github.com/nlpodyssey/cybertron v0.2.1/go.mod h1:Vg9PeB8EkOTAgSKQ68B3hhKUGmB6Vs734dBdCyE4SVM= 67 | github.com/nlpodyssey/gopickle v0.2.0/go.mod h1:YIUwjJ2O7+vnBsxUN+MHAAI3N+adqEGiw+nDpwW95bY= 68 | github.com/nlpodyssey/gotokenizers v0.2.0/go.mod h1:SBLbuSQhpni9M7U+Ie6O46TXYN73T2Cuw/4eeYHYJ+s= 69 | github.com/nlpodyssey/spago v1.1.0/go.mod h1:jDWGZwrB4B61U6Tf3/+MVlWOtNsk3EUA7G13UDHlnjQ= 70 | github.com/oapi-codegen/runtime v1.1.1/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg= 71 | github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= 72 | github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= 73 | github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= 74 | github.com/opensearch-project/opensearch-go v1.1.0/go.mod h1:+6/XHCuTH+fwsMJikZEWsucZ4eZMma3zNSeLrTtVGbo= 75 | github.com/pdevine/tensor v0.0.0-20240228013915-64ccaa8d9ca9/go.mod h1:nR7l3gM6ubiOm+mCkmmUyIBUcBAyiUmW6dQrDZhugFE= 76 | github.com/pgvector/pgvector-go v0.1.1/go.mod h1:wLJgD/ODkdtd2LJK4l6evHXTuG+8PxymYAVomKHOWac= 77 | github.com/pinecone-io/go-pinecone v0.4.1/go.mod h1:KwWSueZFx9zccC+thBk13+LDiOgii8cff9bliUI4tQs= 78 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 79 | github.com/rs/zerolog v1.31.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= 80 | github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU= 81 | github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= 82 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 83 | github.com/temoto/robotstxt v1.1.2/go.mod h1:+1AmkuG3IYkh1kv0d2qEB9Le88ehNO0zwOr3ujewlOo= 84 | github.com/testcontainers/testcontainers-go/modules/milvus v0.29.1/go.mod h1:IQ6CpkAaf2bYmOnr44obiLjyoGQQhaAhq2QfQ9iBM7Q= 85 | github.com/testcontainers/testcontainers-go/modules/mysql v0.29.1/go.mod h1:VhA5dV+O19sx3Y9u9bfO+fbJfP3E7RiMq0nDMEGjslw= 86 | github.com/testcontainers/testcontainers-go/modules/opensearch v0.29.1/go.mod h1:GjgsoovL/4UftnX1fhyjPyXwK+CpJ6Akiqc0o2QFIYY= 87 | github.com/testcontainers/testcontainers-go/modules/postgres v0.29.1/go.mod h1:YsWyy+pHDgvGdi0axGOx6CGXWsE6eqSaApyd1FYYSSc= 88 | github.com/testcontainers/testcontainers-go/modules/qdrant v0.29.1/go.mod h1:e/Xu0sSGSeNN6aPMPWY9hhYTjrBHJHetUI0TZPd9L6g= 89 | github.com/testcontainers/testcontainers-go/modules/weaviate v0.29.1/go.mod h1:3oFt28r1FIrmyrQHHxv6sCbJStO6Z8XdmUYolXQlkAU= 90 | github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= 91 | github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= 92 | github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= 93 | github.com/weaviate/weaviate v1.23.9/go.mod h1:afludwbcyIZa9HEBELvHNb8zjH+KcjcW/jb4SZ5C2T4= 94 | github.com/weaviate/weaviate-go-client/v4 v4.12.1/go.mod h1:r1PlU5sAZKFvAPgymEHQj0hjSAuEV9X77PJ/ffZ6cEo= 95 | github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= 96 | github.com/xtgo/set v1.0.0/go.mod h1:d3NHzGzSa0NmB2NhFyECA+QdRp29oEn2xbT+TpeFoM8= 97 | go.mongodb.org/mongo-driver v1.11.3/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g= 98 | go4.org/unsafe/assume-no-moving-gc v0.0.0-20231121144256-b99613f794b6/go.mod h1:FftLjUGFEDu5k8lt0ddY+HcrH/qU/0qk+H8j9/nTl3E= 99 | gonum.org/v1/gonum v0.8.2/go.mod h1:oe/vMfY3deqTw+1EZJhuvEW2iwGF1bW9wwu7XCu0+v0= 100 | gorgonia.org/vecf32 v0.9.0/go.mod h1:NCc+5D2oxddRL11hd+pCB1PEyXWOyiQxfZ/1wwhOXCA= 101 | gorgonia.org/vecf64 v0.9.0/go.mod h1:hp7IOWCnRiVQKON73kkC/AUMtEXyf9kGlVrtPQ9ccVA= 102 | -------------------------------------------------------------------------------- /metrics/Dockerfile: -------------------------------------------------------------------------------- 1 | # Stage 1: Build 2 | FROM golang:1.22 AS builder 3 | 4 | # Set the Current Working Directory inside the container 5 | WORKDIR /app 6 | 7 | # Copy go mod and sum files 8 | # COPY go.mod go.sum ./ 9 | COPY go.mod ./ 10 | 11 | # Download all dependencies. Dependencies will be cached if the go.mod and go.sum files are not changed 12 | RUN go mod download 13 | 14 | # Copy the source from the current directory to the Working Directory inside the container 15 | COPY . . 16 | 17 | # Build the Go app 18 | RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o main . 19 | 20 | # Stage 2: Run 21 | FROM alpine:latest 22 | ARG VERSION=0.0.1 23 | ENV VERSION=${VERSION} 24 | 25 | RUN apk --no-cache add ca-certificates 26 | 27 | WORKDIR /root/ 28 | 29 | # Copy the Pre-built binary file from the previous stage 30 | COPY --from=builder /app/main . 31 | 32 | # Expose port 8080 to the outside world 33 | EXPOSE 9999 34 | 35 | # Command to run the executable 36 | CMD ["./main"] 37 | -------------------------------------------------------------------------------- /metrics/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/nilsherzig/lsm 2 | 3 | go 1.22.0 4 | -------------------------------------------------------------------------------- /metrics/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "hash/fnv" 7 | "io" 8 | "log/slog" 9 | "net/http" 10 | "os" 11 | "strings" 12 | ) 13 | 14 | type Message struct { 15 | Title string `json:"title"` 16 | Msg string `json:"msg"` 17 | } 18 | 19 | type metricsResponse struct { 20 | Problems []Message `json:"problems"` 21 | } 22 | 23 | type metricsReqBody struct { 24 | Version string `json:"version"` 25 | Model string `json:"model"` 26 | } 27 | 28 | func hash(s string) uint32 { 29 | h := fnv.New32a() 30 | h.Write([]byte(s)) 31 | return h.Sum32() 32 | } 33 | 34 | func versionHandler(w http.ResponseWriter, r *http.Request) { 35 | setCorsHeaders(w) // TODO not needed while proxied through frontend? 36 | if r.Method == "OPTIONS" { 37 | w.WriteHeader(http.StatusOK) 38 | return 39 | } 40 | 41 | if r.Method != "POST" { 42 | w.WriteHeader(http.StatusMethodNotAllowed) 43 | return 44 | } 45 | slog.Info("Request", "method", r.Method, "url", r.URL, "remote", strings.Split(r.RemoteAddr, ":")[0]) 46 | 47 | // read body and log it 48 | body, err := io.ReadAll(r.Body) 49 | if err != nil { 50 | slog.Error("Error reading body", "error", err) 51 | } 52 | rb := metricsReqBody{} 53 | err = json.Unmarshal(body, &rb) 54 | 55 | ipHash := hash(strings.Split(r.RemoteAddr, ":")[0]) 56 | w.WriteHeader(http.StatusOK) 57 | w.Header().Set("Content-Type", "application/json") 58 | 59 | resp := metricsResponse{} 60 | 61 | problems := []Message{} 62 | if rb.Version < latestVersion { 63 | message := fmt.Sprintf("Your version is outdated. Please update your docker containers. Your version: %s, latest version: %s", rb.Version, latestVersion) 64 | problems = append(problems, Message{"Version outdated", message}) 65 | } 66 | 67 | resp.Problems = problems 68 | 69 | slog.Info("Client", "iphash", ipHash, "version", rb.Version, "model", rb.Model, "problems", problems) 70 | json.NewEncoder(w).Encode(resp) 71 | } 72 | 73 | func StartApiServer() { 74 | http.HandleFunc("/v1", versionHandler) 75 | 76 | slog.Info("Starting server at http://localhost:9999") 77 | if err := http.ListenAndServe(":9999", nil); err != nil { 78 | slog.Error("Error starting server", "error", err) 79 | } 80 | } 81 | 82 | var ( 83 | latestVersion string 84 | ok bool 85 | ) 86 | 87 | func main() { 88 | latestVersion, ok = os.LookupEnv("VERSION") 89 | if !ok { 90 | slog.Error("VERSION env var not set") 91 | return 92 | } 93 | StartApiServer() 94 | } 95 | 96 | func setCorsHeaders(w http.ResponseWriter) { 97 | w.Header().Set("Access-Control-Allow-Origin", "*") 98 | w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") 99 | w.Header().Set("Access-Control-Allow-Headers", "Content-Type") 100 | } 101 | -------------------------------------------------------------------------------- /metrics/tmp/build-errors.log: -------------------------------------------------------------------------------- 1 | exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1 -------------------------------------------------------------------------------- /metrics/tmp/main: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nilsherzig/LLocalSearch/6ef2739ac77f07532f0eda5c7b3f94b0b2426a2f/metrics/tmp/main -------------------------------------------------------------------------------- /nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | listen [::]:80; 4 | server_name _; 5 | 6 | location / { 7 | root /usr/share/nginx/html; 8 | try_files $uri $uri/index.html $uri.html /index.html; 9 | } 10 | # proxy /api to the backend, remove the /api/ part 11 | location /api/ { 12 | rewrite ^/api(/.*)$ $1 break; 13 | # dont batch answers 14 | proxy_buffering off; 15 | client_max_body_size 0; 16 | proxy_http_version 1.1; 17 | proxy_request_buffering off; 18 | proxy_pass http://backend:8080/; 19 | proxy_set_header Host $host; 20 | proxy_set_header X-Real-IP $remote_addr; 21 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 22 | proxy_set_header X-Forwarded-Proto $scheme; 23 | } 24 | 25 | # include mime.types; 26 | # types { 27 | # application/javascript js mjs; 28 | # text/html html; 29 | # } 30 | } 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "localsearchui", 3 | "version": "0.0.1", 4 | "private": true, 5 | "scripts": { 6 | "dev": "vite dev", 7 | "build": "vite build", 8 | "preview": "vite preview", 9 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 10 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 11 | "lint": "prettier --check . && eslint .", 12 | "format": "prettier --write ." 13 | }, 14 | "devDependencies": { 15 | "@sveltejs/adapter-auto": "^3.0.0", 16 | "@sveltejs/adapter-node": "^5.0.1", 17 | "@sveltejs/adapter-static": "^3.0.1", 18 | "@sveltejs/kit": "^2.0.0", 19 | "@sveltejs/vite-plugin-svelte": "^3.0.0", 20 | "@tailwindcss/typography": "^0.5.10", 21 | "@types/eslint": "^8.56.0", 22 | "@typescript-eslint/eslint-plugin": "^7.0.0", 23 | "@typescript-eslint/parser": "^7.0.0", 24 | "autoprefixer": "^10.4.18", 25 | "eslint": "^8.56.0", 26 | "eslint-config-prettier": "^9.1.0", 27 | "eslint-plugin-svelte": "^2.35.1", 28 | "postcss": "^8.4.37", 29 | "prettier": "^3.1.1", 30 | "prettier-plugin-svelte": "^3.1.2", 31 | "svelte": "^4.2.7", 32 | "svelte-check": "^3.6.0", 33 | "tailwindcss": "^3.4.1", 34 | "tslib": "^2.4.1", 35 | "typescript": "^5.0.0", 36 | "vite": "^5.1.7" 37 | }, 38 | "type": "module", 39 | "dependencies": { 40 | "express": "^4.19.2", 41 | "http-proxy-middleware": "^3.0.0", 42 | "marked": "^12.0.1", 43 | "node-fetch": "^3.3.2" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /searxng/limiter.toml: -------------------------------------------------------------------------------- 1 | # This configuration file updates the default configuration file 2 | # See https://github.com/searxng/searxng/blob/master/searx/botdetection/limiter.toml 3 | 4 | [botdetection.ip_limit] 5 | # activate link_token method in the ip_limit method 6 | link_token = true 7 | -------------------------------------------------------------------------------- /searxng/settings.yml: -------------------------------------------------------------------------------- 1 | # see https://docs.searxng.org/admin/settings/settings.html#settings-use-default-settings 2 | use_default_settings: true 3 | server: 4 | # base_url is defined in the SEARXNG_BASE_URL environment variable, see .env and docker-compose.yml 5 | secret_key: "51b30631d62b441ec1715009d96cf324f89993401cb2ce8301c4170d2fe6ed13" # change this! 6 | limiter: false # can be disabled for a private instance 7 | image_proxy: true 8 | ui: 9 | static_use_hash: true 10 | redis: 11 | url: redis://redis:6379/0 12 | search: 13 | safe_search: 0 14 | autocomplete: "" 15 | default_lang: "" 16 | ban_time_on_fail: 5 17 | max_ban_time_on_fail: 120 18 | suspended_times: 19 | SearxEngineAccessDenied: 86400 20 | SearxEngineCaptcha: 86400 21 | SearxEngineTooManyRequests: 3600 22 | cf_SearxEngineCaptcha: 1296000 23 | cf_SearxEngineAccessDenied: 86400 24 | recaptcha_SearxEngineCaptcha: 604800 25 | formats: 26 | - html 27 | - json 28 | -------------------------------------------------------------------------------- /searxng/uwsgi.ini: -------------------------------------------------------------------------------- 1 | [uwsgi] 2 | # Who will run the code 3 | uid = searxng 4 | gid = searxng 5 | 6 | # Number of workers (usually CPU count) 7 | # default value: %k (= number of CPU core, see Dockerfile) 8 | workers = %k 9 | 10 | # Number of threads per worker 11 | # default value: 4 (see Dockerfile) 12 | threads = 4 13 | 14 | # The right granted on the created socket 15 | chmod-socket = 666 16 | 17 | # Plugin to use and interpreter config 18 | single-interpreter = true 19 | master = true 20 | plugin = python3 21 | lazy-apps = true 22 | enable-threads = 4 23 | 24 | # Module to import 25 | module = searx.webapp 26 | 27 | # Virtualenv and python path 28 | pythonpath = /usr/local/searxng/ 29 | chdir = /usr/local/searxng/searx/ 30 | 31 | # automatically set processes name to something meaningful 32 | auto-procname = true 33 | 34 | # Disable request logging for privacy 35 | disable-logging = true 36 | log-5xx = true 37 | 38 | # Set the max size of a request (request-body excluded) 39 | buffer-size = 8192 40 | 41 | # No keep alive 42 | # See https://github.com/searx/searx-docker/issues/24 43 | add-header = Connection: close 44 | 45 | # uwsgi serves the static files 46 | static-map = /static=/usr/local/searxng/searx/static 47 | # expires set to one day 48 | static-expires = /* 86400 49 | static-gzip-all = True 50 | offload-threads = 4 51 | -------------------------------------------------------------------------------- /src/app.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /src/app.d.ts: -------------------------------------------------------------------------------- 1 | // See https://kit.svelte.dev/docs/types#app 2 | // for information about these interfaces 3 | declare global { 4 | namespace App { 5 | // interface Error {} 6 | // interface Locals {} 7 | // interface PageData {} 8 | interface PageState { } 9 | // interface Platform {} 10 | } 11 | } 12 | 13 | export { }; 14 | -------------------------------------------------------------------------------- /src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | %sveltekit.head% 9 | 10 | 11 | 12 |
%sveltekit.body%
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/lib/bottom_bar.svelte: -------------------------------------------------------------------------------- 1 | 44 | 45 |
46 |
47 |
50 | 60 | 61 | 62 |
63 | 64 | 65 | 66 |
67 | -------------------------------------------------------------------------------- /src/lib/chatHistory.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 |
7 | {#each chatHistoryItems as chatHistoryItem} 8 | {#if chatHistoryItem.stepType == StepType.HandleStreaming} 9 |
10 | {chatHistoryItem.stepType}: 11 | {chatHistoryItem.message} 12 |
13 | {/if} 14 | {/each} 15 |
16 | -------------------------------------------------------------------------------- /src/lib/chatList.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 |
11 | {#each chatListItems as chatListItem} 12 | {#if chatListItem} 13 | 14 | {/if} 15 | {/each} 16 |
17 | -------------------------------------------------------------------------------- /src/lib/chatListItemElem.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | 30 | -------------------------------------------------------------------------------- /src/lib/chat_button.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 |
15 | {#if sendMode && prompt != ''} 16 | 17 | 39 | 40 | {:else if !sendMode && eventSource} 41 | 42 | 65 | 66 | {:else} 67 | 68 | 106 | 107 | {/if} 108 |
109 | -------------------------------------------------------------------------------- /src/lib/clickOutside.js: -------------------------------------------------------------------------------- 1 | /** Dispatch event on click outside of node */ 2 | export function clickOutside(node) { 3 | const handleClick = (event) => { 4 | if (node && !node.contains(event.target) && !event.defaultPrevented) { 5 | node.dispatchEvent(new CustomEvent('click_outside', node)); 6 | } 7 | }; 8 | 9 | document.addEventListener('click', handleClick, true); 10 | 11 | return { 12 | destroy() { 13 | document.removeEventListener('click', handleClick, true); 14 | } 15 | }; 16 | } 17 | -------------------------------------------------------------------------------- /src/lib/index.ts: -------------------------------------------------------------------------------- 1 | // place files you want to import through the `$lib` alias in this folder. 2 | -------------------------------------------------------------------------------- /src/lib/loading_message.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 |
13 |
14 |
15 | {#each titleNumbers as i} 16 |
17 | {/each} 18 |
19 | 20 |
23 |
24 | {#each numbers as i} 25 | {#if i % 3 === 0} 26 |
27 | {:else if i % 5 === 0} 28 |
29 | {:else if i % 7 === 0} 30 |
31 | {:else if i % 9 === 0} 32 |
33 | {:else} 34 |
35 | {/if} 36 | {/each} 37 |
38 |
39 |
40 | -------------------------------------------------------------------------------- /src/lib/log_item.svelte: -------------------------------------------------------------------------------- 1 | 19 | 20 |
25 |
26 |
27 | 28 | {#if logElement.stepType == StepType.HandleChainError || logElement.stepType == StepType.HandleToolError || logElement.stepType == StepType.HandleLlmError || logElement.stepType == StepType.HandleParseError} 29 |
32 | {#if logElement.message.includes('Parsing Error.')} 33 | Looks like the LLM didn't respond in the right format. It will try again. Consider 35 | using a LLM trained on structured output or function calling. 37 | {:else} 38 |
39 | {@html marked.parse(logElement.message)} 40 |
41 | {/if} 42 |
43 | 44 | {:else if logElement.stepType == StepType.HandleOllamaModelLoadMessage} 45 |
46 | {logElement.message} 47 |
48 | 49 | {:else if logElement.stepType == StepType.HandleUserMessage} 50 |
51 |
54 | {logElement.message} 55 |
56 | 57 | {:else if logElement.stream || logElement.stepType == StepType.HandleFinalAnswer} 58 | {#if logElement.message.includes('Action: webscrape')} 59 |
60 | 68 | 74 | 79 | 80 | {logElement.message.split('Action Input:')[1] || ''} 81 |
82 | {:else if logElement.message.includes('Action: database_search')} 83 |
84 | 92 | 98 | 99 | {logElement.message.split('Action Input:')[1] || ''} 100 |
101 | {:else if logElement.message.includes('Action: websearch')} 102 |
103 | 111 | 117 | 118 | {logElement.message.split('Action Input:')[1] || ''} 119 |
120 | {:else} 121 |
124 |
125 | {@html marked.parse( 126 | logElement.message 127 | .replace('Thought: Do I need to use a tool? No\n', '') 128 | .replace('Thought: Do I need to use a tool? No', '') 129 | .replace('Do I need to use a tool? No', '') 130 | .replace('AI: ', '') 131 | .replace('AI:', '') 132 | )} 133 |
134 | 135 |
136 | {/if} 137 | {/if} 138 | 139 | {#if showLogs} 140 | {#if logElement.stepType == StepType.HandleToolStart} 141 |
144 | Tool start 145 | {logElement.message} 146 |
147 | {:else if logElement.stepType == StepType.HandleAgentAction} 148 |
151 | Agent Action 152 | {logElement.message} 153 |
154 | {:else if logElement.stepType == StepType.HandleChainStart} 155 |
158 | Chain start 159 | {logElement.message} 160 |
161 | {:else if logElement.stepType == StepType.HandleChainEnd} 162 |
165 | Chain end 166 | {logElement.message} 167 |
168 | {/if} 169 | {/if} 170 |
171 |
172 |
173 | -------------------------------------------------------------------------------- /src/lib/log_node.svelte: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 19 | 20 | 21 |

22 | 29 | {#if logElement.parent} 30 | Parent: {logElement.parent.message} 31 | {/if} 32 |

33 | 34 | {#if open} 35 | {#each children as child} 36 | 37 | {/each} 38 | {/if} 39 | -------------------------------------------------------------------------------- /src/lib/model_switch_window.svelte: -------------------------------------------------------------------------------- 1 | 7 | 8 | {#if showModelSwitchWindow} 9 |
10 |
13 |
14 | {#each models as model} 15 | 31 | {/each} 32 |
33 |
34 |
35 | {/if} 36 | -------------------------------------------------------------------------------- /src/lib/new_chat_button.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 16 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/lib/settings_field.svelte: -------------------------------------------------------------------------------- 1 | 39 | 40 |
41 |

42 | {descriptionBefore} 43 | 54 | {descriptionAfter} 55 |

56 |

57 | {#if showRange} 58 | Value must be between {minValue} and {maxValue} 59 | {/if} 60 |

61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 |
71 | -------------------------------------------------------------------------------- /src/lib/settings_window.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | {#if showSettings} 13 |
18 |
{ 21 | showSettings = false; 22 | }} 23 | class="m-4 max-w-prose bg-neutral-50 p-4 rounded-lg shadow-lg border border-neutral-300 dark:bg-neutral-900 dark:border-neutral-800 dark:text-neutral-200 overflow-scroll z-50" 24 | > 25 |
30 |

Settings

31 |
32 | 33 | 55 | 56 | 57 | 79 | 80 |
81 |
82 | 83 |
84 |

LLM

85 |
86 |

87 | The agent chain is using the 88 | 102 | model. 103 |

104 |
105 |
106 |

The llm uses the following system message:

107 |