├── .eslintrc.js ├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ ├── config.yml │ └── feature_request.yml ├── dependabot.yml └── workflows │ ├── appstore-build-publish.yml │ ├── integration-test.yml │ ├── lint-php-cs.yml │ ├── lint-php.yml │ ├── node.yml │ ├── pr-feedback.yml │ ├── psalm.yml │ └── reuse.yml ├── .gitignore ├── .l10nignore ├── .nextcloudignore ├── .php-cs-fixer.dist.php ├── .tx └── config ├── AUTHORS.md ├── CHANGELOG.md ├── COPYING ├── LICENSES ├── AGPL-3.0-or-later.txt ├── Apache-2.0.txt ├── CC0-1.0.txt └── MIT.txt ├── README.md ├── REUSE.toml ├── appinfo ├── info.xml └── routes.php ├── composer.json ├── composer.lock ├── doc └── Implementing_a_provider.md ├── img ├── Logo.png ├── app-dark.svg └── app.svg ├── krankerl.toml ├── l10n └── .gitkeep ├── lib ├── AppInfo │ └── Application.php ├── BackgroundJobs │ ├── ActionJob.php │ ├── IndexerJob.php │ ├── InitialContentImportJob.php │ ├── RotateLogsJob.php │ ├── SchedulerJob.php │ ├── StorageCrawlJob.php │ └── SubmitContentJob.php ├── Command │ ├── Prompt.php │ ├── ScanFiles.php │ ├── Search.php │ └── Statistics.php ├── Controller │ ├── ConfigController.php │ └── ProviderController.php ├── Db │ ├── QueueAction.php │ ├── QueueActionMapper.php │ ├── QueueContentItem.php │ ├── QueueContentItemMapper.php │ ├── QueueFile.php │ └── QueueMapper.php ├── Event │ └── ContentProviderRegisterEvent.php ├── Exceptions │ └── RetryIndexException.php ├── Listener │ ├── AppDisableListener.php │ ├── FileListener.php │ ├── ShareListener.php │ └── UserDeletedListener.php ├── Logger.php ├── Migration │ ├── Version001000000Date20231102094721.php │ ├── Version004000000Date20241206135634.php │ ├── Version004000000Date20241217110041.php │ ├── Version004002001Date20250410110041.php │ ├── Version1001Date20240130120627.php │ ├── Version2002Date20240619004215.php │ ├── Version4000Date20241108004215.php │ └── Version4000Date20241202141755.php ├── Public │ ├── ContentItem.php │ ├── ContentManager.php │ ├── IContentProvider.php │ └── UpdateAccessOp.php ├── Repair │ └── AppInstallStep.php ├── Service │ ├── ActionService.php │ ├── DiagnosticService.php │ ├── LangRopeService.php │ ├── MetadataService.php │ ├── ProviderConfigService.php │ ├── QueueService.php │ ├── ScanService.php │ └── StorageService.php ├── Settings │ ├── AdminSection.php │ └── AdminSettings.php ├── TaskProcessing │ ├── ContextChatProvider.php │ ├── ContextChatSearchProvider.php │ ├── ContextChatSearchTaskType.php │ └── ContextChatTaskType.php └── Type │ ├── ActionType.php │ ├── ScopeType.php │ ├── Source.php │ └── UpdateAccessOp.php ├── makefile ├── package-lock.json ├── package.json ├── psalm.xml ├── screenshots ├── context_chat_1.png ├── context_chat_1.png.license ├── context_chat_2.png ├── context_chat_2.png.license ├── context_chat_4.png ├── context_chat_4.png.license ├── context_chat_5.png └── context_chat_5.png.license ├── src ├── admin.js ├── components │ └── ViewAdmin.vue ├── mixins │ └── AppGlobal.js └── utils.js ├── stubs ├── appapi-public-functions.php ├── cache-query-builder.php ├── doctrine-dbal.php ├── oc-hooks.php └── oc-systemconfig.php ├── stylelint.config.js ├── templates └── admin.php ├── tests ├── bootstrap.php ├── integration │ ├── ContentManagerTest.php │ └── ProviderConfigServiceTest.php ├── phpunit.xml └── psalm-baseline.xml └── webpack.js /.eslintrc.js: -------------------------------------------------------------------------------- 1 | /** 2 | * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors 3 | * SPDX-License-Identifier: AGPL-3.0-or-later 4 | */ 5 | 6 | module.exports = { 7 | globals: { 8 | appVersion: true 9 | }, 10 | parserOptions: { 11 | requireConfigFile: false 12 | }, 13 | extends: [ 14 | '@nextcloud' 15 | ], 16 | rules: { 17 | 'jsdoc/require-jsdoc': 'off', 18 | 'jsdoc/tag-lines': 'off', 19 | 'vue/first-attribute-linebreak': 'off', 20 | 'import/extensions': 'off' 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors 2 | # SPDX-License-Identifier: AGPL-3.0-or-later 3 | # App maintainers 4 | /appinfo/info.xml @julien-nc @marcelklehr 5 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors 2 | # SPDX-License-Identifier: AGPL-3.0-or-later 3 | name: 🐞 Bug Report 4 | description: Create a bug report for context_chat 5 | labels: ['bug'] 6 | body: 7 | - type: markdown 8 | attributes: 9 | value: Thanks for taking the time to file a bug report! Please fill out this form as completely as possible. 10 | - type: input 11 | attributes: 12 | label: Which version of context_chat are you using? 13 | description: 'Please specify the exact version instead of "latest". For example: 2.0.2' 14 | validations: 15 | required: true 16 | - type: input 17 | attributes: 18 | label: Which version of Nextcloud are you using? 19 | description: 'For example: v23.0.1' 20 | validations: 21 | required: true 22 | - type: input 23 | attributes: 24 | label: Which browser are you using? In case you are using the phone App, specify the Android or iOS version and device please. 25 | description: 'Please specify the exact version instead of "latest". For example: Chrome 100.0.4878.0 or ' 26 | - type: textarea 27 | attributes: 28 | label: Describe the Bug 29 | description: A clear and concise description of what the bug is. 30 | validations: 31 | required: true 32 | - type: textarea 33 | attributes: 34 | label: Expected Behavior 35 | description: A clear and concise description of what you expected to happen. 36 | validations: 37 | required: true 38 | - type: textarea 39 | attributes: 40 | label: To Reproduce 41 | description: Steps to reproduce the behavior, please provide a clear number of steps that always reproduces the issue. Screenshots can be provided in the issue body below. 42 | validations: 43 | required: true 44 | - type: markdown 45 | attributes: 46 | value: | 47 | Before posting the issue go through the steps you've written down to make sure the steps provided are detailed and clear. 48 | Contributors should be able to follow the steps provided in order to reproduce the bug. 49 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors 2 | # SPDX-License-Identifier: AGPL-3.0-or-later 3 | contact_links: 4 | - name: 🚨 Report a security or privacy issue 5 | url: https://hackerone.com/nextcloud 6 | about: Report security and privacy related issues privately to the Nextcloud team, so we can coordinate the fix and release without potentially exposing all Nextcloud servers and users in the meantime. 7 | - name: ❓ Community Support and Help 8 | url: https://help.nextcloud.com/ 9 | about: Configuration, webserver/proxy or performance issues and other questions 10 | - name: 💼 Nextcloud Enterprise 11 | url: https://portal.nextcloud.com/ 12 | about: If you are a Nextcloud Enterprise customer, or need Professional support, so it can be resolved directly by our dedicated engineers more quickly 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors 2 | # SPDX-License-Identifier: AGPL-3.0-or-later 3 | name: 🚀 Feature Request 4 | description: Create a feature request for context_chat 5 | labels: ['enhancement'] 6 | body: 7 | - type: markdown 8 | attributes: 9 | value: Thanks for taking the time to file a feature request! Please fill out this form as completely as possible. 10 | - type: textarea 11 | attributes: 12 | label: Describe the feature you'd like to request 13 | description: A clear and concise description of what you want and what your use case is. 14 | validations: 15 | required: true 16 | - type: textarea 17 | attributes: 18 | label: Describe the solution you'd like 19 | description: A clear and concise description of what you want to happen. 20 | validations: 21 | required: true 22 | - type: textarea 23 | attributes: 24 | label: Describe alternatives you've considered 25 | description: A clear and concise description of any alternative solutions or features you've considered. 26 | validations: 27 | required: true 28 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors 2 | # SPDX-License-Identifier: AGPL-3.0-or-later 3 | version: 2 4 | updates: 5 | - package-ecosystem: composer 6 | directory: "/" 7 | schedule: 8 | interval: weekly 9 | day: saturday 10 | time: "03:00" 11 | timezone: Europe/Paris 12 | open-pull-requests-limit: 10 13 | -------------------------------------------------------------------------------- /.github/workflows/lint-php-cs.yml: -------------------------------------------------------------------------------- 1 | # This workflow is provided via the organization template repository 2 | # 3 | # https://github.com/nextcloud/.github 4 | # https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization 5 | # 6 | # SPDX-FileCopyrightText: 2021-2024 Nextcloud GmbH and Nextcloud contributors 7 | # SPDX-License-Identifier: MIT 8 | 9 | name: Lint php-cs 10 | 11 | on: pull_request 12 | 13 | permissions: 14 | contents: read 15 | 16 | concurrency: 17 | group: lint-php-cs-${{ github.head_ref || github.run_id }} 18 | cancel-in-progress: true 19 | 20 | jobs: 21 | lint: 22 | runs-on: ubuntu-latest 23 | 24 | name: php-cs 25 | 26 | steps: 27 | - name: Checkout 28 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 29 | with: 30 | persist-credentials: false 31 | 32 | - name: Get php version 33 | id: versions 34 | uses: icewind1991/nextcloud-version-matrix@58becf3b4bb6dc6cef677b15e2fd8e7d48c0908f # v1.3.1 35 | 36 | - name: Set up php${{ steps.versions.outputs.php-min }} 37 | uses: shivammathur/setup-php@cf4cade2721270509d5b1c766ab3549210a39a2a # v2.33.0 38 | with: 39 | php-version: ${{ steps.versions.outputs.php-min }} 40 | extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, xmlreader, xmlwriter, zip, zlib, sqlite, pdo_sqlite 41 | coverage: none 42 | ini-file: development 43 | env: 44 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 45 | 46 | - name: Install dependencies 47 | run: | 48 | composer remove nextcloud/ocp --dev --no-scripts 49 | composer i 50 | 51 | - name: Lint 52 | run: composer run cs:check || ( echo 'Please run `composer run cs:fix` to format your code' && exit 1 ) 53 | -------------------------------------------------------------------------------- /.github/workflows/lint-php.yml: -------------------------------------------------------------------------------- 1 | # This workflow is provided via the organization template repository 2 | # 3 | # https://github.com/nextcloud/.github 4 | # https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization 5 | # 6 | # SPDX-FileCopyrightText: 2021-2024 Nextcloud GmbH and Nextcloud contributors 7 | # SPDX-License-Identifier: MIT 8 | 9 | name: Lint php 10 | 11 | on: pull_request 12 | 13 | permissions: 14 | contents: read 15 | 16 | concurrency: 17 | group: lint-php-${{ github.head_ref || github.run_id }} 18 | cancel-in-progress: true 19 | 20 | jobs: 21 | matrix: 22 | runs-on: ubuntu-latest-low 23 | outputs: 24 | php-versions: ${{ steps.versions.outputs.php-versions }} 25 | steps: 26 | - name: Checkout app 27 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 28 | with: 29 | persist-credentials: false 30 | 31 | - name: Get version matrix 32 | id: versions 33 | uses: icewind1991/nextcloud-version-matrix@58becf3b4bb6dc6cef677b15e2fd8e7d48c0908f # v1.0.0 34 | 35 | php-lint: 36 | runs-on: ubuntu-latest 37 | needs: matrix 38 | strategy: 39 | matrix: 40 | php-versions: ${{fromJson(needs.matrix.outputs.php-versions)}} 41 | 42 | name: php-lint 43 | 44 | steps: 45 | - name: Checkout 46 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 47 | with: 48 | persist-credentials: false 49 | 50 | - name: Set up php ${{ matrix.php-versions }} 51 | uses: shivammathur/setup-php@cf4cade2721270509d5b1c766ab3549210a39a2a # v2.33.0 52 | with: 53 | php-version: ${{ matrix.php-versions }} 54 | extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, xmlreader, xmlwriter, zip, zlib, sqlite, pdo_sqlite 55 | coverage: none 56 | ini-file: development 57 | env: 58 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 59 | 60 | - name: Lint 61 | run: composer run lint 62 | 63 | summary: 64 | permissions: 65 | contents: none 66 | runs-on: ubuntu-latest-low 67 | needs: php-lint 68 | 69 | if: always() 70 | 71 | name: php-lint-summary 72 | 73 | steps: 74 | - name: Summary status 75 | run: if ${{ needs.php-lint.result != 'success' && needs.php-lint.result != 'skipped' }}; then exit 1; fi 76 | -------------------------------------------------------------------------------- /.github/workflows/node.yml: -------------------------------------------------------------------------------- 1 | # This workflow is provided via the organization template repository 2 | # 3 | # https://github.com/nextcloud/.github 4 | # https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization 5 | # 6 | # SPDX-FileCopyrightText: 2021-2024 Nextcloud GmbH and Nextcloud contributors 7 | # SPDX-License-Identifier: MIT 8 | 9 | name: Node 10 | 11 | on: pull_request 12 | 13 | permissions: 14 | contents: read 15 | 16 | concurrency: 17 | group: node-${{ github.head_ref || github.run_id }} 18 | cancel-in-progress: true 19 | 20 | jobs: 21 | changes: 22 | runs-on: ubuntu-latest-low 23 | permissions: 24 | contents: read 25 | pull-requests: read 26 | 27 | outputs: 28 | src: ${{ steps.changes.outputs.src}} 29 | 30 | steps: 31 | - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 32 | id: changes 33 | continue-on-error: true 34 | with: 35 | filters: | 36 | src: 37 | - '.github/workflows/**' 38 | - 'src/**' 39 | - 'appinfo/info.xml' 40 | - 'package.json' 41 | - 'package-lock.json' 42 | - 'tsconfig.json' 43 | - '**.js' 44 | - '**.ts' 45 | - '**.vue' 46 | 47 | build: 48 | runs-on: ubuntu-latest 49 | 50 | needs: changes 51 | if: needs.changes.outputs.src != 'false' 52 | 53 | name: NPM build 54 | steps: 55 | - name: Checkout 56 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 57 | with: 58 | persist-credentials: false 59 | 60 | - name: Read package.json node and npm engines version 61 | uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3 62 | id: versions 63 | with: 64 | fallbackNode: '^20' 65 | fallbackNpm: '^10' 66 | 67 | - name: Set up node ${{ steps.versions.outputs.nodeVersion }} 68 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 69 | with: 70 | node-version: ${{ steps.versions.outputs.nodeVersion }} 71 | 72 | - name: Set up npm ${{ steps.versions.outputs.npmVersion }} 73 | run: npm i -g 'npm@${{ steps.versions.outputs.npmVersion }}' 74 | 75 | - name: Install dependencies & build 76 | env: 77 | CYPRESS_INSTALL_BINARY: 0 78 | PUPPETEER_SKIP_DOWNLOAD: true 79 | run: | 80 | npm ci 81 | npm run build --if-present 82 | 83 | - name: Check webpack build changes 84 | run: | 85 | bash -c "[[ ! \"`git status --porcelain `\" ]] || (echo 'Please recompile and commit the assets, see the section \"Show changes on failure\" for details' && exit 1)" 86 | 87 | - name: Show changes on failure 88 | if: failure() 89 | run: | 90 | git status 91 | git --no-pager diff 92 | exit 1 # make it red to grab attention 93 | 94 | summary: 95 | permissions: 96 | contents: none 97 | runs-on: ubuntu-latest-low 98 | needs: [changes, build] 99 | 100 | if: always() 101 | 102 | # This is the summary, we just avoid to rename it so that branch protection rules still match 103 | name: node 104 | 105 | steps: 106 | - name: Summary status 107 | run: if ${{ needs.changes.outputs.src != 'false' && needs.build.result != 'success' }}; then exit 1; fi 108 | -------------------------------------------------------------------------------- /.github/workflows/pr-feedback.yml: -------------------------------------------------------------------------------- 1 | # This workflow is provided via the organization template repository 2 | # 3 | # https://github.com/nextcloud/.github 4 | # https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization 5 | 6 | # SPDX-FileCopyrightText: 2023-2024 Nextcloud GmbH and Nextcloud contributors 7 | # SPDX-FileCopyrightText: 2023 Marcel Klehr 8 | # SPDX-FileCopyrightText: 2023 Joas Schilling <213943+nickvergessen@users.noreply.github.com> 9 | # SPDX-FileCopyrightText: 2023 Daniel Kesselberg 10 | # SPDX-FileCopyrightText: 2023 Florian Steffens 11 | # SPDX-License-Identifier: MIT 12 | 13 | name: 'Ask for feedback on PRs' 14 | on: 15 | schedule: 16 | - cron: '30 1 * * *' 17 | 18 | permissions: 19 | contents: read 20 | pull-requests: write 21 | 22 | jobs: 23 | pr-feedback: 24 | if: ${{ github.repository_owner == 'nextcloud' }} 25 | runs-on: ubuntu-latest 26 | steps: 27 | - name: The get-github-handles-from-website action 28 | uses: marcelklehr/get-github-handles-from-website-action@06b2239db0a48fe1484ba0bfd966a3ab81a08308 # v1.0.1 29 | id: scrape 30 | with: 31 | website: 'https://nextcloud.com/team/' 32 | 33 | - name: Get blocklist 34 | id: blocklist 35 | run: | 36 | blocklist=$(curl https://raw.githubusercontent.com/nextcloud/.github/master/non-community-usernames.txt | paste -s -d, -) 37 | echo "blocklist=$blocklist" >> "$GITHUB_OUTPUT" 38 | 39 | - uses: nextcloud/pr-feedback-action@1883b38a033fb16f576875e0cf45f98b857655c4 # main 40 | with: 41 | feedback-message: | 42 | Hello there, 43 | Thank you so much for taking the time and effort to create a pull request to our Nextcloud project. 44 | 45 | We hope that the review process is going smooth and is helpful for you. We want to ensure your pull request is reviewed to your satisfaction. If you have a moment, our community management team would very much appreciate your feedback on your experience with this PR review process. 46 | 47 | Your feedback is valuable to us as we continuously strive to improve our community developer experience. Please take a moment to complete our short survey by clicking on the following link: https://cloud.nextcloud.com/apps/forms/s/i9Ago4EQRZ7TWxjfmeEpPkf6 48 | 49 | Thank you for contributing to Nextcloud and we hope to hear from you soon! 50 | 51 | (If you believe you should not receive this message, you can add yourself to the [blocklist](https://github.com/nextcloud/.github/blob/master/non-community-usernames.txt).) 52 | days-before-feedback: 14 53 | start-date: '2024-04-30' 54 | exempt-authors: '${{ steps.blocklist.outputs.blocklist }},${{ steps.scrape.outputs.users }}' 55 | exempt-bots: true 56 | -------------------------------------------------------------------------------- /.github/workflows/psalm.yml: -------------------------------------------------------------------------------- 1 | # This workflow is provided via the organization template repository 2 | # 3 | # https://github.com/nextcloud/.github 4 | # https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization 5 | # 6 | # SPDX-FileCopyrightText: 2022-2024 Nextcloud GmbH and Nextcloud contributors 7 | # SPDX-License-Identifier: MIT 8 | 9 | name: Static analysis 10 | 11 | on: pull_request 12 | 13 | concurrency: 14 | group: psalm-${{ github.head_ref || github.run_id }} 15 | cancel-in-progress: true 16 | 17 | permissions: 18 | contents: read 19 | 20 | jobs: 21 | static-analysis: 22 | runs-on: ubuntu-latest 23 | 24 | name: static-psalm-analysis 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 28 | with: 29 | persist-credentials: false 30 | 31 | - name: Get php version 32 | id: versions 33 | uses: icewind1991/nextcloud-version-matrix@58becf3b4bb6dc6cef677b15e2fd8e7d48c0908f # v1.3.1 34 | 35 | - name: Check enforcement of minimum PHP version ${{ steps.versions.outputs.php-min }} in psalm.xml 36 | run: grep 'phpVersion="${{ steps.versions.outputs.php-min }}' psalm.xml 37 | 38 | - name: Set up php${{ steps.versions.outputs.php-available }} 39 | uses: shivammathur/setup-php@cf4cade2721270509d5b1c766ab3549210a39a2a # v2.33.0 40 | with: 41 | php-version: ${{ steps.versions.outputs.php-available }} 42 | extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, xmlreader, xmlwriter, zip, zlib, sqlite, pdo_sqlite 43 | coverage: none 44 | ini-file: development 45 | # Temporary workaround for missing pcntl_* in PHP 8.3 46 | ini-values: disable_functions= 47 | env: 48 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 49 | 50 | - name: Install dependencies 51 | run: | 52 | composer remove nextcloud/ocp --dev --no-scripts 53 | composer i 54 | 55 | - name: Install nextcloud/ocp 56 | run: composer require --dev nextcloud/ocp:dev-${{ steps.versions.outputs.branches-max }} --ignore-platform-reqs --with-dependencies 57 | 58 | - name: Run coding standards check 59 | run: composer run psalm -- --threads=1 --monochrome --no-progress --output-format=github 60 | -------------------------------------------------------------------------------- /.github/workflows/reuse.yml: -------------------------------------------------------------------------------- 1 | # This workflow is provided via the organization template repository 2 | # 3 | # https://github.com/nextcloud/.github 4 | # https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization 5 | 6 | # SPDX-FileCopyrightText: 2022 Free Software Foundation Europe e.V. 7 | # 8 | # SPDX-License-Identifier: CC0-1.0 9 | 10 | name: REUSE Compliance Check 11 | 12 | on: [pull_request] 13 | 14 | permissions: 15 | contents: read 16 | 17 | jobs: 18 | reuse-compliance-check: 19 | runs-on: ubuntu-latest-low 20 | steps: 21 | - name: Checkout 22 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 23 | with: 24 | persist-credentials: false 25 | 26 | - name: REUSE Compliance Check 27 | uses: fsfe/reuse-action@bb774aa972c2a89ff34781233d275075cbddf542 # v5.0.0 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors 2 | # SPDX-License-Identifier: AGPL-3.0-or-later 3 | js/ 4 | .code-workspace 5 | .DS_Store 6 | .idea/ 7 | .vscode/ 8 | .vscode-upload.json 9 | .*.sw* 10 | .php-cs-fixer.cache 11 | node_modules 12 | 13 | /vendor/ 14 | tests/clover.xml 15 | tests/.phpunit.result.cache 16 | -------------------------------------------------------------------------------- /.l10nignore: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors 2 | # SPDX-License-Identifier: AGPL-3.0-or-later 3 | # compiled vue templates 4 | js/ 5 | vendor/ 6 | -------------------------------------------------------------------------------- /.nextcloudignore: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors 2 | # SPDX-License-Identifier: AGPL-3.0-or-later 3 | .git 4 | .github 5 | .gitignore 6 | .tx 7 | .vscode 8 | .php-cs-fixer.* 9 | /.codecov.yml 10 | /.eslintrc.js 11 | /.gitattributes 12 | /.gitignore 13 | /.l10nignore 14 | /.nextcloudignore 15 | /.travis.yml 16 | /.pre-commit-config.yaml 17 | /babel.config.js 18 | /build 19 | /CODE_OF_CONDUCT.md 20 | /composer.* 21 | /node_modules 22 | /screenshots 23 | /src 24 | /vendor/bin 25 | /jest.config.js 26 | /Makefile 27 | /makefile 28 | /krankerl.toml 29 | /package-lock.json 30 | /package.json 31 | /postcss.config.js 32 | /psalm.xml 33 | /pyproject.toml 34 | /renovate.json 35 | /stylelint.config.js 36 | /webpack.config.js 37 | /webpack.js 38 | tests 39 | -------------------------------------------------------------------------------- /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | getFinder() 17 | ->ignoreVCSIgnored(true) 18 | ->notPath('build') 19 | ->notPath('l10n') 20 | ->notPath('src') 21 | ->notPath('node_modules') 22 | ->notPath('vendor') 23 | ->in(__DIR__); 24 | 25 | return $config; 26 | -------------------------------------------------------------------------------- /.tx/config: -------------------------------------------------------------------------------- 1 | [main] 2 | host = https://www.transifex.com 3 | lang_map = hu_HU: hu, nb_NO: nb, sk_SK: sk, th_TH: th, ja_JP: ja, bg_BG: bg, cs_CZ: cs, fi_FI: fi 4 | 5 | [o:nextcloud:p:nextcloud:r:context_chat] 6 | file_filter = translationfiles//context_chat.po 7 | source_file = translationfiles/templates/context_chat.pot 8 | source_lang = en 9 | type = PO 10 | 11 | -------------------------------------------------------------------------------- /AUTHORS.md: -------------------------------------------------------------------------------- 1 | 5 | # Authors 6 | 7 | - Andy Scherzinger 8 | - Anupam Kumar 9 | - Daphne Muller <86835268+DaphneMuller@users.noreply.github.com> 10 | - Julien Veyssier 11 | - Marcel Klehr 12 | - Rello 13 | -------------------------------------------------------------------------------- /LICENSES/CC0-1.0.txt: -------------------------------------------------------------------------------- 1 | Creative Commons Legal Code 2 | 3 | CC0 1.0 Universal 4 | 5 | CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE 6 | LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN 7 | ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS 8 | INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES 9 | REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS 10 | PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM 11 | THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED 12 | HEREUNDER. 13 | 14 | Statement of Purpose 15 | 16 | The laws of most jurisdictions throughout the world automatically confer 17 | exclusive Copyright and Related Rights (defined below) upon the creator 18 | and subsequent owner(s) (each and all, an "owner") of an original work of 19 | authorship and/or a database (each, a "Work"). 20 | 21 | Certain owners wish to permanently relinquish those rights to a Work for 22 | the purpose of contributing to a commons of creative, cultural and 23 | scientific works ("Commons") that the public can reliably and without fear 24 | of later claims of infringement build upon, modify, incorporate in other 25 | works, reuse and redistribute as freely as possible in any form whatsoever 26 | and for any purposes, including without limitation commercial purposes. 27 | These owners may contribute to the Commons to promote the ideal of a free 28 | culture and the further production of creative, cultural and scientific 29 | works, or to gain reputation or greater distribution for their Work in 30 | part through the use and efforts of others. 31 | 32 | For these and/or other purposes and motivations, and without any 33 | expectation of additional consideration or compensation, the person 34 | associating CC0 with a Work (the "Affirmer"), to the extent that he or she 35 | is an owner of Copyright and Related Rights in the Work, voluntarily 36 | elects to apply CC0 to the Work and publicly distribute the Work under its 37 | terms, with knowledge of his or her Copyright and Related Rights in the 38 | Work and the meaning and intended legal effect of CC0 on those rights. 39 | 40 | 1. Copyright and Related Rights. A Work made available under CC0 may be 41 | protected by copyright and related or neighboring rights ("Copyright and 42 | Related Rights"). Copyright and Related Rights include, but are not 43 | limited to, the following: 44 | 45 | i. the right to reproduce, adapt, distribute, perform, display, 46 | communicate, and translate a Work; 47 | ii. moral rights retained by the original author(s) and/or performer(s); 48 | iii. publicity and privacy rights pertaining to a person's image or 49 | likeness depicted in a Work; 50 | iv. rights protecting against unfair competition in regards to a Work, 51 | subject to the limitations in paragraph 4(a), below; 52 | v. rights protecting the extraction, dissemination, use and reuse of data 53 | in a Work; 54 | vi. database rights (such as those arising under Directive 96/9/EC of the 55 | European Parliament and of the Council of 11 March 1996 on the legal 56 | protection of databases, and under any national implementation 57 | thereof, including any amended or successor version of such 58 | directive); and 59 | vii. other similar, equivalent or corresponding rights throughout the 60 | world based on applicable law or treaty, and any national 61 | implementations thereof. 62 | 63 | 2. Waiver. To the greatest extent permitted by, but not in contravention 64 | of, applicable law, Affirmer hereby overtly, fully, permanently, 65 | irrevocably and unconditionally waives, abandons, and surrenders all of 66 | Affirmer's Copyright and Related Rights and associated claims and causes 67 | of action, whether now known or unknown (including existing as well as 68 | future claims and causes of action), in the Work (i) in all territories 69 | worldwide, (ii) for the maximum duration provided by applicable law or 70 | treaty (including future time extensions), (iii) in any current or future 71 | medium and for any number of copies, and (iv) for any purpose whatsoever, 72 | including without limitation commercial, advertising or promotional 73 | purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each 74 | member of the public at large and to the detriment of Affirmer's heirs and 75 | successors, fully intending that such Waiver shall not be subject to 76 | revocation, rescission, cancellation, termination, or any other legal or 77 | equitable action to disrupt the quiet enjoyment of the Work by the public 78 | as contemplated by Affirmer's express Statement of Purpose. 79 | 80 | 3. Public License Fallback. Should any part of the Waiver for any reason 81 | be judged legally invalid or ineffective under applicable law, then the 82 | Waiver shall be preserved to the maximum extent permitted taking into 83 | account Affirmer's express Statement of Purpose. In addition, to the 84 | extent the Waiver is so judged Affirmer hereby grants to each affected 85 | person a royalty-free, non transferable, non sublicensable, non exclusive, 86 | irrevocable and unconditional license to exercise Affirmer's Copyright and 87 | Related Rights in the Work (i) in all territories worldwide, (ii) for the 88 | maximum duration provided by applicable law or treaty (including future 89 | time extensions), (iii) in any current or future medium and for any number 90 | of copies, and (iv) for any purpose whatsoever, including without 91 | limitation commercial, advertising or promotional purposes (the 92 | "License"). The License shall be deemed effective as of the date CC0 was 93 | applied by Affirmer to the Work. Should any part of the License for any 94 | reason be judged legally invalid or ineffective under applicable law, such 95 | partial invalidity or ineffectiveness shall not invalidate the remainder 96 | of the License, and in such case Affirmer hereby affirms that he or she 97 | will not (i) exercise any of his or her remaining Copyright and Related 98 | Rights in the Work or (ii) assert any associated claims and causes of 99 | action with respect to the Work, in either case contrary to Affirmer's 100 | express Statement of Purpose. 101 | 102 | 4. Limitations and Disclaimers. 103 | 104 | a. No trademark or patent rights held by Affirmer are waived, abandoned, 105 | surrendered, licensed or otherwise affected by this document. 106 | b. Affirmer offers the Work as-is and makes no representations or 107 | warranties of any kind concerning the Work, express, implied, 108 | statutory or otherwise, including without limitation warranties of 109 | title, merchantability, fitness for a particular purpose, non 110 | infringement, or the absence of latent or other defects, accuracy, or 111 | the present or absence of errors, whether or not discoverable, all to 112 | the greatest extent permissible under applicable law. 113 | c. Affirmer disclaims responsibility for clearing rights of other persons 114 | that may apply to the Work or any use thereof, including without 115 | limitation any person's Copyright and Related Rights in the Work. 116 | Further, Affirmer disclaims responsibility for obtaining any necessary 117 | consents, permissions or other rights required for any use of the 118 | Work. 119 | d. Affirmer understands and acknowledges that Creative Commons is not a 120 | party to this document and has no duty or obligation with respect to 121 | this CC0 or use of the Work. 122 | -------------------------------------------------------------------------------- /LICENSES/MIT.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 5 | # Nextcloud Assistant Context Chat 6 | 7 | [![REUSE status](https://api.reuse.software/badge/github.com/nextcloud/context_chat)](https://api.reuse.software/info/github.com/nextcloud/context_chat) 8 | 9 | ![](https://raw.githubusercontent.com/nextcloud/context_chat/main/img/Logo.png) 10 | 11 | ## Install 12 | 1. Install two other mandatory apps for this app to work as desired in your Nextcloud install from the "Apps" page: 13 | - AppAPI (>= v2.0.x): https://apps.nextcloud.com/apps/app_api 14 | - Assistant: https://apps.nextcloud.com/apps/assistant (The OCS API or the `occ` commands can also be used to interact with this app but it recommended to do that through a Text Processing OCP API consumer like the Assistant app.) 15 | 2. Install this app (Nextcloud Assistant Context Chat): https://apps.nextcloud.com/apps/context_chat 16 | 3. Install the Context Chat Backend app (https://apps.nextcloud.com/apps/context_chat_backend) from the "External Apps" page. It is important to note here that the backend app should have the same major and minor version as this app (context_chat) 17 | 4. Start using Context Chat from the Assistant UI 18 | 19 | > [!NOTE] 20 | > Refer to the [Context Chat Backend's readme](https://github.com/nextcloud/context_chat_backend/?tab=readme-ov-file) and the [AppAPI's documentation](https://cloud-py-api.github.io/app_api/) for help with setup of AppAPI's deploy daemon. 21 | > See the [NC Admin docs](https://docs.nextcloud.com/server/latest/admin_manual/ai/app_context_chat.html) for requirements and known limitations. 22 | > 23 | > The HTTP request timeout is 50 minutes for all requests except deletion requests, which have 3 seconds timeout. The 50 minutes timeout can be changed with the `request_timeout` app config. The same also needs to be done for docker socket proxy (if you're using that). See [Slow responding ExApps](https://github.com/cloud-py-api/docker-socket-proxy?tab=readme-ov-file#slow-responding-exapps) 24 | > 25 | > Please open an issue if you need help :) 26 | -------------------------------------------------------------------------------- /REUSE.toml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors 2 | # SPDX-License-Identifier: AGPL-3.0-or-later 3 | version = 1 4 | SPDX-PackageName = "context_chat" 5 | SPDX-PackageSupplier = "Nextcloud " 6 | SPDX-PackageDownloadLocation = "https://github.com/nextcloud/context_chat" 7 | 8 | [[annotations]] 9 | path = ["l10n/**.js", "l10n/**.json"] 10 | precedence = "aggregate" 11 | SPDX-FileCopyrightText = "2023-2024 Nextcloud translators" 12 | SPDX-License-Identifier = "AGPL-3.0-or-later" 13 | 14 | [[annotations]] 15 | path = [".tx/config", "composer.json", "composer.lock", "package-lock.json", "package.json"] 16 | precedence = "aggregate" 17 | SPDX-FileCopyrightText = "2023 Nextcloud GmbH and Nextcloud contributors" 18 | SPDX-License-Identifier = "AGPL-3.0-or-later" 19 | 20 | [[annotations]] 21 | path = ["img/app.svg", "img/app-dark.svg", "img/Logo.png"] 22 | precedence = "aggregate" 23 | SPDX-FileCopyrightText = "2018-2024 Google LLC" 24 | SPDX-License-Identifier = "Apache-2.0" 25 | -------------------------------------------------------------------------------- /appinfo/info.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | context_chat 8 | Nextcloud Assistant Context Chat 9 | Chat with your documents 10 | 25 | 4.3.0 26 | agpl 27 | Julien Veyssier 28 | Anupam Kumar 29 | Marcel Klehr 30 | ContextChat 31 | 32 | https://github.com/nextcloud/context_chat 33 | 34 | integration 35 | https://github.com/nextcloud/context_chat 36 | https://github.com/nextcloud/context_chat/issues 37 | https://raw.githubusercontent.com/nextcloud/context_chat/main/img/Logo.png 38 | https://raw.githubusercontent.com/nextcloud/context_chat/main/screenshots/context_chat_1.png 39 | https://raw.githubusercontent.com/nextcloud/context_chat/main/screenshots/context_chat_2.png 40 | https://raw.githubusercontent.com/nextcloud/context_chat/main/screenshots/context_chat_4.png 41 | https://raw.githubusercontent.com/nextcloud/context_chat/main/screenshots/context_chat_5.png 42 | 43 | 44 | 45 | 46 | OCA\ContextChat\BackgroundJobs\SchedulerJob 47 | OCA\ContextChat\BackgroundJobs\RotateLogsJob 48 | 49 | 50 | OCA\ContextChat\Command\Prompt 51 | OCA\ContextChat\Command\Search 52 | OCA\ContextChat\Command\ScanFiles 53 | OCA\ContextChat\Command\Statistics 54 | 55 | 56 | 57 | OCA\ContextChat\Repair\AppInstallStep 58 | 59 | 60 | 61 | 62 | OCA\ContextChat\Settings\AdminSettings 63 | OCA\ContextChat\Settings\AdminSection 64 | 65 | 66 | -------------------------------------------------------------------------------- /appinfo/routes.php: -------------------------------------------------------------------------------- 1 | [ 10 | ['name' => 'config#setConfig', 'url' => '/config', 'verb' => 'PUT'], 11 | ['name' => 'config#setAdminConfig', 'url' => '/admin-config', 'verb' => 'PUT'], 12 | 13 | ['name' => 'provider#getProviders', 'url' => '/providers', 'verb' => 'GET'], 14 | ['name' => 'provider#getDefaultProviderKey', 'url' => '/default-provider-key', 'verb' => 'GET'], 15 | ['name' => 'provider#getMetadataFor', 'url' => '/sources-metadata', 'verb' => 'POST'], 16 | ], 17 | ]; 18 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nextcloud/context_chat", 3 | "description": "Context Chat Companion App", 4 | "type": "project", 5 | "license": "AGPL-3.0-or-later", 6 | "authors": [ 7 | { 8 | "name": "Julien Veyssier", 9 | "email": "julien-nc@posteo.net" 10 | }, 11 | { 12 | "name": "Anupam Kumar", 13 | "email": "kyteinsky@gmail.com" 14 | }, 15 | { 16 | "name": "Marcel Klehr", 17 | "email": "mklehr@gmx.net" 18 | } 19 | ], 20 | "require": { 21 | "php": "^8.1 || ^8.2 || ^8.3 || ^8.4" 22 | }, 23 | "require-dev": { 24 | "nextcloud/coding-standard": "^1.3.2", 25 | "nextcloud/ocp": "dev-master", 26 | "roave/security-advisories": "dev-latest", 27 | "phpunit/phpunit": "^10.5", 28 | "vimeo/psalm": "^6.11.0" 29 | }, 30 | "scripts": { 31 | "lint": "find . -name \\*.php -not -path './vendor/*' -print0 | xargs -0 -n1 php -l", 32 | "cs:check": "php-cs-fixer fix --dry-run --diff", 33 | "cs:fix": "php-cs-fixer fix", 34 | "psalm": "psalm --threads=1 --no-cache", 35 | "psalm:update-baseline": "psalm --threads=1 --update-baseline", 36 | "psalm:update-baseline:force": "psalm --threads=1 --update-baseline --set-baseline=tests/psalm-baseline.xml", 37 | "psalm:clear": "psalm --clear-cache && psalm --clear-global-cache", 38 | "psalm:fix": "psalm --alter --issues=InvalidReturnType,InvalidNullableReturnType,MissingParamType,InvalidFalsableReturnType", 39 | "test": "phpunit --configuration tests/phpunit.xml" 40 | }, 41 | "config": { 42 | "optimize-autoloader": true, 43 | "classmap-authoritative": true, 44 | "platform": { 45 | "php": "8.1.31" 46 | } 47 | }, 48 | "autoload": { 49 | "psr-4": { 50 | "OCA\\ContextChat\\": "lib/" 51 | } 52 | }, 53 | "autoload-dev": { 54 | "psr-4": { 55 | "OCP\\": "vendor/nextcloud/ocp/OCP" 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /doc/Implementing_a_provider.md: -------------------------------------------------------------------------------- 1 | 5 | # How to implement a content provider for Context Chat 6 | 7 | ### The content provider interface 8 | A content provider for context chat needs to implement the `\OCA\ContextChat\Public\IContentProvider` interface: 9 | 10 | ```php 11 | /** 12 | * This interface defines methods to implement a content provider 13 | * @since 1.1.0 14 | */ 15 | interface IContentProvider { 16 | /** 17 | * The ID of the provider 18 | * 19 | * @return string 20 | * @since 1.1.0 21 | */ 22 | public function getId(): string; 23 | 24 | /** 25 | * The ID of the app making the provider avaialble 26 | * 27 | * @return string 28 | * @since 1.1.0 29 | */ 30 | public function getAppId(): string; 31 | 32 | /** 33 | * The absolute URL to the content item 34 | * 35 | * @param string $id 36 | * @return string 37 | * @since 1.1.0 38 | */ 39 | public function getItemUrl(string $id): string; 40 | 41 | /** 42 | * Starts the initial import of content items into content chat 43 | * 44 | * @return void 45 | * @since 1.1.0 46 | */ 47 | public function triggerInitialImport(): void; 48 | } 49 | ``` 50 | 51 | The `triggerInitialImport` method is called when context chat is first set up and allows your app to import all existing content into context chat in one bulk. Any other items that are created afterwards will need to be added on demand. 52 | 53 | ### The content manager service 54 | To add content and register your provider implementation you will need to use the `\OCA\ContextChat\Public\ContentManager` service. 55 | 56 | The ContentManager has the following methods: 57 | 58 | * `registerContentProvider(string $providerClass)` 59 | * `submitContent(string $appId, array $items)` Providers can use this to submit content for indexing in context chat. 60 | * `removeContentForUsers(string $appId, string $providerId, string $itemId, array $users)` Remove a content item from the knowledge base of context chat for specified users. (deprecated) 61 | * `removeAllContentForUsers(string $appId, string $providerId, array $users)` Remove all content items from the knowledge base of context chat for specified users. (deprecated) 62 | * `updateAccess(string $appId, string $providerId, string $itemId, string $op, array $userIds)` Update the access rights for a content item. Use \OCA\ContextChat\Public\UpdateAccessOp constants for the $op operation. 63 | * `updateAccessProvider(string $appId, string $providerId, string $op, array $userIds)` Update the access rights for all content items of a provider. Use \OCA\ContextChat\Public\UpdateAccessOp constants for the $op operation. 64 | * `updateAccessDeclarative(string $appId, string $providerId, string $itemId, array $userIds)` Update the access rights for a content item. This method is declarative and will replace the current access rights with the provided ones. 65 | * `deleteProvider(string $appId, string $providerId)` Remove all content items of a provider from the knowledge base of context chat. 66 | * `deleteContent(string $appId, string $providerId, array $itemIds)` Remove content items from the knowledge base of context chat. 67 | 68 | ### The event implementation 69 | To register your content provider, your app needs to listen to the `OCA\ContextChat\Event\ContentProviderRegisterEvent` event and call the `registerContentProvider` method in the event for every provider you want to register. 70 | 71 | #### Application.php (partially for reference) 72 | ```php 73 | use OCA\ContextChat\Event\ContentProviderRegisterEvent; 74 | use OCA\xxx\ContextChat\ContentProvider; 75 | ... 76 | $context->registerEventListener(ContentProviderRegisterEvent::class, ContentProvider::class); 77 | ``` 78 | 79 | #### ContentProvider (partially for reference) 80 | ```php 81 | class ContentProvider implements IContentProvider { 82 | ... 83 | public function handle(Event $event): void { 84 | if (!$event instanceof ContentProviderRegisterEvent) { 85 | return; 86 | } 87 | $event->registerContentProvider(***appId***, ***providerId***', ContentProvider::class); 88 | } 89 | ``` 90 | 91 | Any interaction with the content manager using the Content Manager's methods or listing the providers in the Assistant should automatically register the provider. 92 | 93 | You may call the `registerContentProvider` method explicitly if you want to trigger an initial import of content items. 94 | 95 | ### The content item 96 | To submit content, wrap it in a `\OCA\ContextChat\Public\ContentItem` object: 97 | 98 | ```php 99 | new ContentItem( 100 | string $itemId, 101 | string $providerId, 102 | string $title, 103 | string $content, 104 | string $documentType, 105 | \DateTime $lastModified, 106 | array $users, 107 | ) 108 | ``` 109 | 110 | `documentType` is a natural language term for your document type in English, e.g. `E-Mail` or `Bookmark`. 111 | 112 | ### Note 113 | 114 | 1. Ensure the item IDs are unique across all users for a given provider. 115 | 2. App ID and provider ID cannot contain double underscores `__`, spaces ` `, or colons `:`. 116 | -------------------------------------------------------------------------------- /img/Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nextcloud/context_chat/dd359b8d1d1f33e25c55b4f5d242fae1917a7498/img/Logo.png -------------------------------------------------------------------------------- /img/app-dark.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /img/app.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /krankerl.toml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors 2 | # SPDX-License-Identifier: AGPL-3.0-or-later 3 | [package] 4 | before_cmds = [ 5 | "npm install --deps", 6 | "npm run build", 7 | ] 8 | -------------------------------------------------------------------------------- /l10n/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nextcloud/context_chat/dd359b8d1d1f33e25c55b4f5d242fae1917a7498/l10n/.gitkeep -------------------------------------------------------------------------------- /lib/AppInfo/Application.php: -------------------------------------------------------------------------------- 1 | getContainer(); 71 | $this->config = $container->get(IConfig::class); 72 | } 73 | 74 | public function register(IRegistrationContext $context): void { 75 | $context->registerEventListener(BeforeNodeDeletedEvent::class, FileListener::class); 76 | $context->registerEventListener(NodeCreatedEvent::class, FileListener::class); 77 | $context->registerEventListener(CacheEntryInsertedEvent::class, FileListener::class); 78 | $context->registerEventListener(NodeRenamedEvent::class, FileListener::class); 79 | $context->registerEventListener(NodeRemovedFromCache::class, FileListener::class); 80 | $context->registerEventListener(NodeWrittenEvent::class, FileListener::class); 81 | $context->registerEventListener(AppDisableEvent::class, AppDisableListener::class); 82 | $context->registerEventListener(UserDeletedEvent::class, UserDeletedListener::class); 83 | $context->registerEventListener(ShareCreatedEvent::class, ShareListener::class); 84 | $context->registerEventListener(ShareDeletedEvent::class, ShareListener::class); 85 | $context->registerTaskProcessingTaskType(ContextChatTaskType::class); 86 | $context->registerTaskProcessingProvider(ContextChatProvider::class); 87 | $context->registerTaskProcessingTaskType(ContextChatSearchTaskType::class); 88 | $context->registerTaskProcessingProvider(ContextChatSearchProvider::class); 89 | } 90 | 91 | public function boot(IBootContext $context): void { 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /lib/BackgroundJobs/ActionJob.php: -------------------------------------------------------------------------------- 1 | appManager->isInstalled('app_api')) { 39 | $this->logger->warning('ActionJob is skipped as app_api is disabled'); 40 | return; 41 | } 42 | 43 | $this->diagnosticService->sendJobStart(static::class, $this->getId()); 44 | $this->diagnosticService->sendHeartbeat(static::class, $this->getId()); 45 | $entities = $this->actionMapper->getFromQueue(static::BATCH_SIZE); 46 | 47 | if (empty($entities)) { 48 | return; 49 | } 50 | 51 | try { 52 | foreach ($entities as $entity) { 53 | $this->diagnosticService->sendHeartbeat(static::class, $this->getId()); 54 | 55 | try { 56 | switch ($entity->getType()) { 57 | case ActionType::DELETE_SOURCE_IDS: 58 | $decoded = json_decode($entity->getPayload(), true); 59 | if (!is_array($decoded) || !isset($decoded['sourceIds'])) { 60 | $this->logger->warning('Invalid payload for DELETE_SOURCE_IDS action', ['payload' => $entity->getPayload()]); 61 | break; 62 | } 63 | $this->networkService->deleteSources($decoded['sourceIds']); 64 | break; 65 | 66 | case ActionType::DELETE_PROVIDER_ID: 67 | $decoded = json_decode($entity->getPayload(), true); 68 | if (!is_array($decoded) || !isset($decoded['providerId'])) { 69 | $this->logger->warning('Invalid payload for DELETE_PROVIDER_ID action', ['payload' => $entity->getPayload()]); 70 | break; 71 | } 72 | $this->networkService->deleteProvider($decoded['providerId']); 73 | break; 74 | 75 | case ActionType::DELETE_USER_ID: 76 | $decoded = json_decode($entity->getPayload(), true); 77 | if (!is_array($decoded) || !isset($decoded['userId'])) { 78 | $this->logger->warning('Invalid payload for DELETE_USER_ID action', ['payload' => $entity->getPayload()]); 79 | break; 80 | } 81 | $this->networkService->deleteUser($decoded['userId']); 82 | break; 83 | 84 | case ActionType::UPDATE_ACCESS_SOURCE_ID: 85 | $decoded = json_decode($entity->getPayload(), true); 86 | if (!is_array($decoded) || !isset($decoded['op']) || !isset($decoded['userIds']) || !isset($decoded['sourceId'])) { 87 | $this->logger->warning('Invalid payload for UPDATE_ACCESS_SOURCE_ID action', ['payload' => $entity->getPayload()]); 88 | break; 89 | } 90 | $this->networkService->updateAccess($decoded['op'], $decoded['userIds'], $decoded['sourceId']); 91 | break; 92 | 93 | case ActionType::UPDATE_ACCESS_PROVIDER_ID: 94 | $decoded = json_decode($entity->getPayload(), true); 95 | if (!is_array($decoded) || !isset($decoded['op']) || !isset($decoded['userIds']) || !isset($decoded['providerId'])) { 96 | $this->logger->warning('Invalid payload for UPDATE_ACCESS_PROVIDER_ID action', ['payload' => $entity->getPayload()]); 97 | break; 98 | } 99 | $this->networkService->updateAccessProvider($decoded['op'], $decoded['userIds'], $decoded['providerId']); 100 | break; 101 | 102 | case ActionType::UPDATE_ACCESS_DECL_SOURCE_ID: 103 | $decoded = json_decode($entity->getPayload(), true); 104 | if (!is_array($decoded) || !isset($decoded['userIds']) || !isset($decoded['sourceId'])) { 105 | $this->logger->warning('Invalid payload for UPDATE_ACCESS_DECL_SOURCE_ID action', ['payload' => $entity->getPayload()]); 106 | break; 107 | } 108 | $this->networkService->updateAccessDeclarative($decoded['userIds'], $decoded['sourceId']); 109 | break; 110 | 111 | default: 112 | $this->logger->warning('Unknown action type', ['type' => $entity->getType()]); 113 | } 114 | $this->diagnosticService->sendHeartbeat(static::class, $this->getId()); 115 | $this->actionMapper->removeFromQueue($entity); 116 | } catch (\RuntimeException $e) { 117 | $this->logger->warning('Error performing action "' . $entity->getType() . '": ' . $e->getMessage(), ['exception' => $e]); 118 | } 119 | } 120 | } catch (\Throwable $e) { 121 | // schedule in 5mins 122 | $this->jobList->scheduleAfter(static::class, $this->time->getTime() + 5 * 60); 123 | throw $e; 124 | } 125 | 126 | // schedule in 5mins 127 | $this->jobList->scheduleAfter(static::class, $this->time->getTime() + 5 * 60); 128 | $this->diagnosticService->sendJobEnd(static::class, $this->getId()); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /lib/BackgroundJobs/InitialContentImportJob.php: -------------------------------------------------------------------------------- 1 | $argument Provider class name 37 | * @return void 38 | */ 39 | protected function run($argument): void { 40 | if (!is_string($argument)) { 41 | return; 42 | } 43 | 44 | try { 45 | /** @var IContentProvider */ 46 | $providerObj = Server::get($argument); 47 | } catch (ContainerExceptionInterface|NotFoundExceptionInterface $e) { 48 | $this->logger->warning('[InitialContentImportJob] Could not run initial import for content provider', ['exception' => $e]); 49 | return; 50 | } 51 | 52 | if (!$this->appManager->isEnabledForUser($providerObj->getAppId())) { 53 | $this->logger->info('[InitialContentImportJob] App is not enabled for user, skipping content import', ['appId' => $providerObj->getAppId()]); 54 | return; 55 | } 56 | 57 | $registeredProviders = $this->providerConfig->getProviders(); 58 | $identifier = ProviderConfigService::getConfigKey($providerObj->getAppId(), $providerObj->getId()); 59 | if (!isset($registeredProviders[$identifier]) 60 | || $registeredProviders[$identifier]['isInitiated'] 61 | ) { 62 | $this->logger->info('[InitialContentImportJob] Provider has already been initiated, skipping content import', ['provider' => $identifier]); 63 | return; 64 | } 65 | 66 | $providerObj->triggerInitialImport(); 67 | $this->providerConfig->updateProvider($providerObj->getAppId(), $providerObj->getId(), $argument, true); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /lib/BackgroundJobs/RotateLogsJob.php: -------------------------------------------------------------------------------- 1 | setInterval(60 * 60 * 3); 25 | } 26 | 27 | protected function run($argument): void { 28 | $default = $this->config->getSystemValue('datadirectory', \OC::$SERVERROOT . '/data') . '/context_chat.log'; 29 | $this->filePath = $this->config->getAppValue('context_chat', 'logfile', $default); 30 | 31 | $this->maxSize = $this->config->getSystemValue('log_rotate_size', 100 * 1024 * 1024); 32 | 33 | if ($this->shouldRotateBySize()) { 34 | $this->rotate(); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /lib/BackgroundJobs/SchedulerJob.php: -------------------------------------------------------------------------------- 1 | appConfig->setAppValueString('indexed_files_count', (string)0); 38 | $this->appConfig->setAppValueInt('last_indexed_time', 0); 39 | foreach ($this->storageService->getMounts() as $mount) { 40 | $this->logger->debug('Scheduling StorageCrawlJob storage_id=' . $mount['storage_id'] . ' root_id=' . $mount['root_id' ] . 'override_root=' . $mount['override_root']); 41 | $this->jobList->add(StorageCrawlJob::class, [ 42 | 'storage_id' => $mount['storage_id'], 43 | 'root_id' => $mount['root_id' ], 44 | 'override_root' => $mount['override_root'], 45 | 'last_file_id' => 0, 46 | ]); 47 | } 48 | 49 | $this->jobList->remove(self::class); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /lib/BackgroundJobs/StorageCrawlJob.php: -------------------------------------------------------------------------------- 1 | jobList->remove(self::class, $argument); 53 | 54 | $this->diagnosticService->sendJobStart(static::class, $this->getId()); 55 | $this->diagnosticService->sendHeartbeat(static::class, $this->getId()); 56 | 57 | $i = 0; 58 | foreach ($this->storageService->getFilesInMount($storageId, $overrideRoot, $lastFileId, self::BATCH_SIZE) as $fileId) { 59 | $queueFile = new QueueFile(); 60 | $queueFile->setStorageId($storageId); 61 | $queueFile->setRootId($rootId); 62 | $queueFile->setFileId($fileId); 63 | $queueFile->setUpdate(false); 64 | $this->diagnosticService->sendHeartbeat(static::class, $this->getId()); 65 | try { 66 | $this->queue->insertIntoQueue($queueFile); 67 | } catch (Exception $e) { 68 | $this->logger->error('[StorageCrawlJob] Failed to add file to queue', [ 69 | 'fileId' => $fileId, 70 | 'exception' => $e, 71 | 'storage_id' => $storageId, 72 | 'root_id' => $rootId, 73 | 'override_root' => $overrideRoot, 74 | 'last_file_id' => $lastFileId 75 | ]); 76 | } 77 | $i++; 78 | } 79 | 80 | if ($i > 0) { 81 | // Schedule next iteration after 5 minutes 82 | $this->jobList->scheduleAfter(self::class, $this->time->getTime() + $this->getJobInterval(), [ 83 | 'storage_id' => $storageId, 84 | 'root_id' => $rootId, 85 | 'override_root' => $overrideRoot, 86 | 'last_file_id' => $queueFile->getFileId(), 87 | ]); 88 | 89 | // the last job to set this value will win 90 | $this->appConfig->setValueInt(Application::APP_ID, 'last_indexed_file_id', $queueFile->getFileId()); 91 | } 92 | $this->diagnosticService->sendJobEnd(static::class, $this->getId()); 93 | } 94 | 95 | protected function getJobInterval(): int { 96 | return $this->appConfig->getValueInt(Application::APP_ID, 'crawl_job_interval', self::DEFAULT_JOB_INTERVAL); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /lib/BackgroundJobs/SubmitContentJob.php: -------------------------------------------------------------------------------- 1 | mapper->getFromQueue(static::BATCH_SIZE); 45 | $maxSize = $this->appConfig->getAppValueInt('indexing_max_size', Application::CC_MAX_SIZE); 46 | 47 | if (empty($entities)) { 48 | return; 49 | } 50 | 51 | $sources = array_map(function (QueueContentItem $item) use ($maxSize) { 52 | $contentSize = mb_strlen($item->getContent(), '8bit'); 53 | if ($contentSize > $maxSize) { 54 | $this->logger->warning('[SubmitContentJob] Content too large to index', [ 55 | 'contentSize' => $contentSize, 56 | 'maxSize' => $maxSize, 57 | 'itemId' => $item->getItemId(), 58 | 'providerId' => $item->getProviderId(), 59 | 'appId' => $item->getAppId(), 60 | ]); 61 | return null; 62 | } 63 | 64 | $providerKey = ProviderConfigService::getConfigKey($item->getAppId(), $item->getProviderId()); 65 | $sourceId = ProviderConfigService::getSourceId($item->getItemId(), $providerKey); 66 | return new Source( 67 | explode(',', $item->getUsers()), 68 | $sourceId, 69 | $item->getTitle(), 70 | $item->getContent(), 71 | $item->getLastModified()->getTimeStamp(), 72 | $item->getDocumentType(), 73 | $providerKey, 74 | ); 75 | }, $entities); 76 | $sources = array_filter($sources); 77 | 78 | try { 79 | $loadSourcesResult = $this->service->indexSources($sources); 80 | $this->logger->info('[SubmitContentJob] Indexed sources for providers', [ 81 | 'count' => count($loadSourcesResult['loaded_sources']), 82 | 'loaded_sources' => $loadSourcesResult['loaded_sources'], 83 | 'sources_to_retry' => $loadSourcesResult['sources_to_retry'], 84 | ]); 85 | } catch (RetryIndexException $e) { 86 | $this->logger->debug('[SubmitContentJob] At least one source is already being processed from another request, trying again soon', ['exception' => $e]); 87 | // schedule in 5mins 88 | $this->jobList->scheduleAfter(static::class, $this->time->getTime() + 5 * 60); 89 | return; 90 | } 91 | 92 | foreach ($entities as $entity) { 93 | $providerKey = ProviderConfigService::getConfigKey($entity->getAppId(), $entity->getProviderId()); 94 | $sourceId = ProviderConfigService::getSourceId($entity->getItemId(), $providerKey); 95 | if (!in_array($sourceId, $loadSourcesResult['sources_to_retry'])) { 96 | $this->mapper->removeFromQueue($entity); 97 | } 98 | } 99 | 100 | // schedule in 5mins 101 | $this->jobList->scheduleAfter(static::class, $this->time->getTime() + 5 * 60); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /lib/Command/Prompt.php: -------------------------------------------------------------------------------- 1 | setName('context_chat:prompt') 30 | ->setDescription('Prompt Nextcloud Assistant Context Chat') 31 | ->addArgument( 32 | 'uid', 33 | InputArgument::REQUIRED, 34 | 'The ID of the user to prompt the documents of' 35 | ) 36 | ->addArgument( 37 | 'prompt', 38 | InputArgument::REQUIRED, 39 | 'The prompt' 40 | ) 41 | ->addOption( 42 | 'context-sources', 43 | null, 44 | InputOption::VALUE_REQUIRED, 45 | 'Context sources to use (as a comma-separated list without brackets)', 46 | ) 47 | ->addOption( 48 | 'context-providers', 49 | null, 50 | InputOption::VALUE_REQUIRED, 51 | 'Context providers to use (as a comma-separated list without brackets)', 52 | ); 53 | } 54 | 55 | protected function execute(InputInterface $input, OutputInterface $output): int { 56 | $userId = $input->getArgument('uid'); 57 | $prompt = $input->getArgument('prompt'); 58 | $contextSources = $input->getOption('context-sources'); 59 | $contextProviders = $input->getOption('context-providers'); 60 | 61 | if (!empty($contextSources) && !empty($contextProviders)) { 62 | throw new \InvalidArgumentException('Cannot use --context-sources with --context-provider'); 63 | } 64 | 65 | if (!empty($contextSources)) { 66 | $contextSources = preg_replace('/\s*,+\s*/', ',', $contextSources); 67 | $contextSourcesArray = array_filter(explode(',', $contextSources), fn ($source) => !empty($source)); 68 | $task = new Task(ContextChatTaskType::ID, [ 69 | 'scopeType' => ScopeType::SOURCE, 70 | 'scopeList' => $contextSourcesArray, 71 | 'scopeListMeta' => '', 72 | 'prompt' => $prompt, 73 | ], 'context_chat', $userId); 74 | } elseif (!empty($contextProviders)) { 75 | $contextProviders = preg_replace('/\s*,+\s*/', ',', $contextProviders); 76 | $contextProvidersArray = array_filter(explode(',', $contextProviders), fn ($source) => !empty($source)); 77 | $task = new Task(ContextChatTaskType::ID, [ 78 | 'scopeType' => ScopeType::PROVIDER, 79 | 'scopeList' => $contextProvidersArray, 80 | 'scopeListMeta' => '', 81 | 'prompt' => $prompt, 82 | ], 'context_chat', $userId); 83 | } else { 84 | $task = new Task(ContextChatTaskType::ID, [ 'prompt' => $prompt, 'scopeType' => ScopeType::NONE, 'scopeList' => [], 'scopeListMeta' => '' ], 'context_chat', $userId); 85 | } 86 | 87 | $this->taskProcessingManager->scheduleTask($task); 88 | while (!in_array(($task = $this->taskProcessingManager->getTask($task->getId()))->getStatus(), [Task::STATUS_FAILED, Task::STATUS_SUCCESSFUL], true)) { 89 | sleep(1); 90 | } 91 | if ($task->getStatus() === Task::STATUS_SUCCESSFUL) { 92 | $output->writeln(var_export($task->getOutput(), true)); 93 | return 0; 94 | } else { 95 | $output->writeln($task->getErrorMessage()); 96 | return 1; 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /lib/Command/ScanFiles.php: -------------------------------------------------------------------------------- 1 | setName('context_chat:scan') 28 | ->setDescription('Scan user files') 29 | ->addArgument( 30 | 'user_id', 31 | InputArgument::REQUIRED, 32 | 'The user ID to scan the storage of' 33 | ) 34 | ->addOption('mimetype', 'm', InputOption::VALUE_REQUIRED, 'The mime type filter') 35 | ->addOption('directory', 'd', InputOption::VALUE_REQUIRED, 'The directory to scan, relative to the user\'s home directory'); 36 | } 37 | 38 | protected function execute(InputInterface $input, OutputInterface $output): int { 39 | $mimeTypeFilter = $input->getOption('mimetype') !== null 40 | ? explode(',', $input->getOption('mimetype')) 41 | : Application::MIMETYPES; 42 | 43 | if ($mimeTypeFilter === false) { 44 | $output->writeln('Invalid mime type filter'); 45 | return 1; 46 | } 47 | 48 | $userId = $input->getArgument('user_id'); 49 | $scan = $this->scanService->scanUserFiles($userId, $mimeTypeFilter, $input->getOption('directory')); 50 | foreach ($scan as $s) { 51 | $output->writeln('[' . $userId . '] Scanned ' . $s->title); 52 | } 53 | 54 | return 0; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /lib/Command/Search.php: -------------------------------------------------------------------------------- 1 | setName('context_chat:search') 30 | ->setDescription('Search with Nextcloud Assistant Context Chat') 31 | ->addArgument( 32 | 'uid', 33 | InputArgument::REQUIRED, 34 | 'The ID of the user to search the documents of' 35 | ) 36 | ->addArgument( 37 | 'prompt', 38 | InputArgument::REQUIRED, 39 | 'The prompt' 40 | ) 41 | ->addOption( 42 | 'context-providers', 43 | null, 44 | InputOption::VALUE_REQUIRED, 45 | 'Context providers to use (as a comma-separated list without brackets)', 46 | ); 47 | } 48 | 49 | protected function execute(InputInterface $input, OutputInterface $output) { 50 | $userId = $input->getArgument('uid'); 51 | $prompt = $input->getArgument('prompt'); 52 | $contextProviders = $input->getOption('context-providers'); 53 | 54 | if (!empty($contextProviders)) { 55 | $contextProviders = preg_replace('/\s*,+\s*/', ',', $contextProviders); 56 | $contextProvidersArray = array_filter(explode(',', $contextProviders), fn ($source) => !empty($source)); 57 | $task = new Task(ContextChatSearchTaskType::ID, [ 58 | 'prompt' => $prompt, 59 | 'scopeType' => ScopeType::PROVIDER, 60 | 'scopeList' => $contextProvidersArray, 61 | 'scopeListMeta' => '', 62 | ], 'context_chat', $userId); 63 | } else { 64 | $task = new Task(ContextChatSearchTaskType::ID, [ 65 | 'prompt' => $prompt, 66 | 'scopeType' => ScopeType::NONE, 67 | 'scopeList' => [], 68 | 'scopeListMeta' => '', 69 | ], 'context_chat', $userId); 70 | } 71 | 72 | $this->taskProcessingManager->scheduleTask($task); 73 | while (!in_array(($task = $this->taskProcessingManager->getTask($task->getId()))->getStatus(), [Task::STATUS_FAILED, Task::STATUS_SUCCESSFUL], true)) { 74 | sleep(1); 75 | } 76 | if ($task->getStatus() === Task::STATUS_SUCCESSFUL) { 77 | $output->writeln(var_export($task->getOutput(), true)); 78 | return 0; 79 | } else { 80 | $output->writeln('' . $task->getErrorMessage() . ''); 81 | return 1; 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /lib/Command/Statistics.php: -------------------------------------------------------------------------------- 1 | setName('context_chat:stats') 38 | ->setDescription('Check ContextChat statistics'); 39 | } 40 | 41 | protected function execute(InputInterface $input, OutputInterface $output): int { 42 | $output->writeln('ContextChat statistics:'); 43 | if ($this->appConfig->getAppValueInt('last_indexed_time', 0) === 0) { 44 | $output->writeln('The indexing is not complete yet.'); 45 | } else { 46 | $installedTime = $this->appConfig->getAppValueInt('installed_time', 0); 47 | $lastIndexedTime = $this->appConfig->getAppValueInt('last_indexed_time', 0); 48 | $indexTime = $lastIndexedTime - $installedTime; 49 | 50 | $output->writeln('Installed time: ' . (new \DateTime('@' . $installedTime))->format('Y-m-d H:i') . ' UTC'); 51 | $output->writeln('Index complete time: ' . (new \DateTime('@' . $lastIndexedTime))->format('Y-m-d H:i') . ' UTC'); 52 | $output->writeln('Total time taken for complete index: ' . strval(floor($indexTime / (60 * 60 * 24))) . ' days ' . gmdate('H:i', $indexTime) . ' (hh:mm)'); 53 | } 54 | 55 | $eligibleFilesCount = $this->storageService->countFiles(); 56 | $output->writeln('Total eligible files: ' . $eligibleFilesCount); 57 | 58 | $queueCount = $this->queueService->count(); 59 | $output->writeln('Files in indexing queue: ' . $queueCount); 60 | 61 | $queuedDocumentsCount = $this->contentQueue->count(); 62 | $output->writeln('Queued documents (without files):' . var_export($queuedDocumentsCount, true)); 63 | 64 | $indexFilesCount = Util::numericToNumber($this->appConfig->getAppValueString('indexed_files_count', '0')); 65 | $output->writeln('Files successfully sent to backend: ' . strval($indexFilesCount)); 66 | 67 | $indexedDocumentsCount = $this->langRopeService->getIndexedDocumentsCounts(); 68 | $output->writeln('Indexed documents: ' . var_export($indexedDocumentsCount, true)); 69 | 70 | $actionsCount = $this->actionService->count(); 71 | $output->writeln('Actions in queue: ' . $actionsCount); 72 | return 0; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /lib/Controller/ConfigController.php: -------------------------------------------------------------------------------- 1 | $value) { 40 | $this->config->setUserValue($this->userId, Application::APP_ID, $key, $value); 41 | } 42 | return new DataResponse(1); 43 | } 44 | 45 | /** 46 | * Set admin config values 47 | * 48 | * @param array $values key/value pairs to store in app config 49 | * @return DataResponse 50 | */ 51 | public function setAdminConfig(array $values): DataResponse { 52 | foreach ($values as $key => $value) { 53 | $this->config->setAppValue(Application::APP_ID, $key, $value); 54 | } 55 | return new DataResponse(1); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /lib/Controller/ProviderController.php: -------------------------------------------------------------------------------- 1 | metadataService->getEnrichedProviders(); 42 | return new DataResponse(array_values($providers)); 43 | } 44 | 45 | /** 46 | * @param array $sources 47 | * @return DataResponse 48 | */ 49 | #[NoAdminRequired] 50 | public function getMetadataFor(array $sources): DataResponse { 51 | $enrichedSources = $this->metadataService->getEnrichedSources(...$sources); 52 | return new DataResponse($enrichedSources); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /lib/Db/QueueAction.php: -------------------------------------------------------------------------------- 1 | addType('id', 'integer'); 36 | $this->addType('type', 'string'); 37 | $this->addType('payload', 'string'); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /lib/Db/QueueActionMapper.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | class QueueActionMapper extends QBMapper { 20 | /** 21 | * @var IDBConnection $db 22 | */ 23 | protected $db; 24 | 25 | public function __construct(IDBConnection $db) { 26 | parent::__construct($db, 'context_chat_action_queue', QueueAction::class); 27 | } 28 | 29 | /** 30 | * @param int $limit 31 | * @return array 32 | * @throws \OCP\DB\Exception 33 | */ 34 | public function getFromQueue(int $limit): array { 35 | $qb = $this->db->getQueryBuilder(); 36 | $qb->select(QueueAction::$columns) 37 | ->from($this->getTableName()) 38 | ->setMaxResults($limit); 39 | 40 | return $this->findEntities($qb); 41 | } 42 | 43 | /** 44 | * @param QueueAction $item 45 | * @return void 46 | * @throws \OCP\DB\Exception 47 | */ 48 | public function removeFromQueue(QueueAction $item): void { 49 | $qb = $this->db->getQueryBuilder(); 50 | $qb->delete($this->getTableName()) 51 | ->where($qb->expr()->eq('id', $qb->createPositionalParameter($item->getId()))) 52 | ->executeStatement(); 53 | } 54 | 55 | /** 56 | * @param QueueAction $item 57 | * @return void 58 | * @throws \OCP\DB\Exception 59 | */ 60 | public function insertIntoQueue(QueueAction $item): void { 61 | $qb = $this->db->getQueryBuilder(); 62 | $qb->insert($this->getTableName()) 63 | ->values([ 64 | 'type' => $qb->createPositionalParameter($item->getType(), IQueryBuilder::PARAM_STR), 65 | 'payload' => $qb->createPositionalParameter($item->getPayload(), IQueryBuilder::PARAM_STR), 66 | ]) 67 | ->executeStatement(); 68 | } 69 | 70 | /** 71 | * @throws \OCP\DB\Exception 72 | */ 73 | public function count() : int { 74 | $qb = $this->db->getQueryBuilder(); 75 | $result = $qb->select($qb->func()->count('id')) 76 | ->from($this->getTableName()) 77 | ->executeQuery(); 78 | if (($cnt = $result->fetchOne()) !== false) { 79 | return (int)$cnt; 80 | } 81 | return 0; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /lib/Db/QueueContentItem.php: -------------------------------------------------------------------------------- 1 | addType('id', Types::INTEGER); 73 | $this->addType('itemId', Types::STRING); 74 | $this->addType('appId', Types::STRING); 75 | $this->addType('providerId', Types::STRING); 76 | $this->addType('title', Types::STRING); 77 | $this->addType('content', Types::STRING); 78 | $this->addType('documentType', Types::STRING); 79 | $this->addType('lastModified', Types::DATETIME); 80 | $this->addType('users', Types::STRING); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /lib/Db/QueueContentItemMapper.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | class QueueContentItemMapper extends QBMapper { 20 | /** 21 | * @var IDBConnection $db 22 | */ 23 | protected $db; 24 | 25 | public function __construct(IDBConnection $db) { 26 | parent::__construct($db, 'context_chat_content_queue', QueueContentItem::class); 27 | } 28 | 29 | /** 30 | * @param int $limit 31 | * @return array 32 | * @throws \OCP\DB\Exception 33 | */ 34 | public function getFromQueue(int $limit): array { 35 | $qb = $this->db->getQueryBuilder(); 36 | $qb->select(QueueContentItem::$columns) 37 | ->from($this->getTableName()) 38 | ->setMaxResults($limit); 39 | 40 | return $this->findEntities($qb); 41 | } 42 | 43 | /** 44 | * @param QueueContentItem $item 45 | * @return void 46 | * @throws \OCP\DB\Exception 47 | */ 48 | public function removeFromQueue(QueueContentItem $item): void { 49 | $qb = $this->db->getQueryBuilder(); 50 | $qb->delete($this->getTableName()) 51 | ->where($qb->expr()->eq('id', $qb->createPositionalParameter($item->getId()))) 52 | ->executeStatement(); 53 | } 54 | 55 | /** 56 | * @throws \OCP\DB\Exception 57 | * @return array 58 | */ 59 | public function count() : array { 60 | $qb = $this->db->getQueryBuilder(); 61 | $result = $qb->select($qb->func()->count('id', 'count'), 'app_id', 'provider_id') 62 | ->from($this->getTableName()) 63 | ->groupBy('app_id', 'provider_id') 64 | ->executeQuery(); 65 | $stats = []; 66 | while (($row = $result->fetch()) !== false) { 67 | $provider = ProviderConfigService::getConfigKey($row['app_id'], $row['provider_id']); 68 | $stats[$provider] = $row['count']; 69 | } 70 | return $stats; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /lib/Db/QueueFile.php: -------------------------------------------------------------------------------- 1 | addType('id', 'integer'); 43 | $this->addType('fileId', 'integer'); 44 | $this->addType('storageId', 'integer'); 45 | $this->addType('rootId', 'integer'); 46 | $this->addType('update', 'boolean'); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /lib/Db/QueueMapper.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | class QueueMapper extends QBMapper { 24 | /** 25 | * @var IDBConnection $db 26 | */ 27 | protected $db; 28 | 29 | public function __construct(IDBConnection $db) { 30 | parent::__construct($db, 'context_chat_queue', QueueFile::class); 31 | } 32 | 33 | /** 34 | * @param int $storageId 35 | * @param int $n 36 | * @return list 37 | * @throws \OCP\DB\Exception 38 | */ 39 | public function getFromQueue(int $storageId, int $rootId, int $n) : array { 40 | $qb = $this->db->getQueryBuilder(); 41 | $qb->select(QueueFile::$columns) 42 | ->from($this->getTableName()) 43 | ->where($qb->expr()->eq('storage_id', $qb->createPositionalParameter($storageId, IQueryBuilder::PARAM_INT))) 44 | ->andWhere($qb->expr()->eq('root_id', $qb->createPositionalParameter($rootId, IQueryBuilder::PARAM_INT))) 45 | ->setMaxResults($n) 46 | ->orderBy('id', 'ASC'); 47 | 48 | return $this->findEntities($qb); 49 | } 50 | 51 | /** 52 | * @param QueueFile[] $files 53 | * @return void 54 | * @throws \OCP\DB\Exception 55 | */ 56 | public function removeFromQueue(array $files): void { 57 | $ids = array_map(fn (QueueFile $file) => $file->getId(), $files); 58 | $chunkSize = 1000; // Maximum number of items in an "IN" expression 59 | foreach (array_chunk($ids, $chunkSize) as $chunk) { 60 | $qb = $this->db->getQueryBuilder(); 61 | $qb->delete($this->getTableName()) 62 | ->where($qb->expr()->in('id', $qb->createPositionalParameter($chunk, IQueryBuilder::PARAM_INT_ARRAY))) 63 | ->executeStatement(); 64 | } 65 | } 66 | 67 | 68 | /** 69 | * @param QueueFile $file 70 | * @return bool 71 | */ 72 | public function existsQueueItem(QueueFile $file) : bool { 73 | $qb = $this->db->getQueryBuilder(); 74 | $qb->select(QueueFile::$columns) 75 | ->from($this->getTableName()) 76 | ->where($qb->expr()->eq('file_id', $qb->createPositionalParameter($file->getFileId(), IQueryBuilder::PARAM_INT))) 77 | ->setMaxResults(1); 78 | 79 | try { 80 | $this->findEntity($qb); 81 | return true; 82 | } catch (DoesNotExistException $e) { 83 | return false; 84 | } catch (MultipleObjectsReturnedException $e) { 85 | return false; 86 | } catch (Exception $e) { 87 | return false; 88 | } 89 | } 90 | 91 | /** 92 | * @param QueueFile $file 93 | * @return QueueFile 94 | * @throws \OCP\DB\Exception 95 | */ 96 | public function insertIntoQueue(QueueFile $file) : QueueFile { 97 | $qb = $this->db->getQueryBuilder(); 98 | $qb->insert($this->getTableName()) 99 | ->values([ 100 | 'file_id' => $qb->createPositionalParameter($file->getFileId(), IQueryBuilder::PARAM_INT), 101 | 'storage_id' => $qb->createPositionalParameter($file->getStorageId(), IQueryBuilder::PARAM_INT), 102 | 'root_id' => $qb->createPositionalParameter($file->getRootId(), IQueryBuilder::PARAM_INT), 103 | 'update' => $qb->createPositionalParameter($file->getUpdate(), IQueryBuilder::PARAM_BOOL) 104 | ]) 105 | ->executeStatement(); 106 | $file->setId($qb->getLastInsertId()); 107 | return $file; 108 | } 109 | 110 | public function clearQueue(): void { 111 | $qb = $this->db->getQueryBuilder(); 112 | $qb->delete($this->getTableName())->executeStatement(); 113 | } 114 | 115 | /** 116 | * @throws \OCP\DB\Exception 117 | */ 118 | public function count() : int { 119 | $qb = $this->db->getQueryBuilder(); 120 | $result = $qb->select($qb->func()->count('id')) 121 | ->from($this->getTableName()) 122 | ->executeQuery(); 123 | if (($cnt = $result->fetchOne()) !== false) { 124 | return (int)$cnt; 125 | } 126 | return 0; 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /lib/Event/ContentProviderRegisterEvent.php: -------------------------------------------------------------------------------- 1 | $providerClass 24 | * @return void 25 | * @since 2.2.2 26 | */ 27 | public function registerContentProvider(string $appId, string $providerId, string $providerClass): void { 28 | $this->contentManager->registerContentProvider($appId, $providerId, $providerClass); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /lib/Exceptions/RetryIndexException.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | class AppDisableListener implements IEventListener { 23 | public function __construct( 24 | private ProviderConfigService $providerConfig, 25 | private ActionService $actionService, 26 | private Logger $logger, 27 | ) { 28 | } 29 | 30 | public function handle(Event $event): void { 31 | if (!($event instanceof AppDisableEvent)) { 32 | return; 33 | } 34 | 35 | foreach ($this->providerConfig->getProviders() as $key => $values) { 36 | /** @var string[] */ 37 | $identifierValues = explode('__', $key, 2); 38 | 39 | if (empty($identifierValues)) { 40 | $this->logger->warning('Invalid provider key', ['key' => $key]); 41 | continue; 42 | } 43 | 44 | [$appId, $providerId] = $identifierValues; 45 | 46 | if ($appId !== $event->getAppId()) { 47 | continue; 48 | } 49 | 50 | $this->providerConfig->removeProvider($appId, $providerId); 51 | $this->actionService->deleteProvider($providerId); 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /lib/Listener/FileListener.php: -------------------------------------------------------------------------------- 1 | 40 | */ 41 | class FileListener implements IEventListener { 42 | 43 | public function __construct( 44 | private Logger $logger, 45 | private QueueService $queue, 46 | private IRootFolder $rootFolder, 47 | private ActionService $actionService, 48 | private StorageService $storageService, 49 | private IManager $shareManager, 50 | ) { 51 | } 52 | 53 | public function handle(Event $event): void { 54 | if ($event instanceof NodeWrittenEvent) { 55 | $node = $event->getNode(); 56 | if (!$node instanceof File) { 57 | return; 58 | } 59 | $this->postInsert($node, false, true); 60 | } 61 | 62 | if ($event instanceof BeforeNodeDeletedEvent) { 63 | $this->postDelete($event->getNode(), false); 64 | return; 65 | } 66 | 67 | if ($event instanceof NodeCreatedEvent) { 68 | $this->postInsert($event->getNode(), false); 69 | return; 70 | } 71 | 72 | if ($event instanceof CacheEntryInsertedEvent) { 73 | $node = current($this->rootFolder->getById($event->getFileId())); 74 | if ($node === false) { 75 | return; 76 | } 77 | if ($node instanceof Folder) { 78 | return; 79 | } 80 | $this->postInsert($node); 81 | return; 82 | } 83 | 84 | if ($event instanceof NodeRenamedEvent) { 85 | $targetNode = $event->getTarget(); 86 | 87 | if ($targetNode instanceof Folder) { 88 | $files = $this->storageService->getAllFilesInFolder($targetNode); 89 | } else { 90 | $files = [$targetNode]; 91 | } 92 | 93 | foreach ($files as $file) { 94 | if (!$file instanceof File) { 95 | continue; 96 | } 97 | $shareAccessList = $this->shareManager->getAccessList($file, true, true); 98 | /** 99 | * @var string[] $shareUserIds 100 | */ 101 | $shareUserIds = array_keys($shareAccessList['users']); 102 | $fileUserIds = $this->storageService->getUsersForFileId($file->getId()); 103 | 104 | $userIds = array_values(array_unique(array_merge($shareUserIds, $fileUserIds))); 105 | $fileRef = ProviderConfigService::getSourceId($file->getId()); 106 | $this->actionService->updateAccessDeclSource($userIds, $fileRef); 107 | } 108 | return; 109 | } 110 | 111 | if ($event instanceof NodeRemovedFromCache) { 112 | $cacheEntry = $event->getStorage()->getCache()->get($event->getPath()); 113 | if ($cacheEntry === false) { 114 | return; 115 | } 116 | $node = current($this->rootFolder->getById($cacheEntry->getId())); 117 | if ($node === false) { 118 | return; 119 | } 120 | $this->postDelete($node); 121 | } 122 | } 123 | 124 | public function postDelete(Node $node, bool $recurse = true): void { 125 | if (!$node instanceof File) { 126 | if (!$recurse) { 127 | return; 128 | } 129 | // For normal inserts we probably get one event per node, but, when removing an ignore file, 130 | // we only get the folder passed here, so we recurse. 131 | try { 132 | /** @var Folder $node */ 133 | foreach ($node->getDirectoryListing() as $child) { 134 | $this->postDelete($child); 135 | } 136 | } catch (NotFoundException $e) { 137 | $this->logger->warning($e->getMessage(), ['exception' => $e]); 138 | } 139 | return; 140 | } 141 | 142 | if (!$this->allowedMimeType($node)) { 143 | return; 144 | } 145 | 146 | $fileRef = ProviderConfigService::getSourceId($node->getId()); 147 | $this->actionService->deleteSources($fileRef); 148 | } 149 | 150 | /** 151 | * @throws \OCP\Files\InvalidPathException 152 | */ 153 | public function postInsert(Node $node, bool $recurse = true, bool $update = false): void { 154 | if ($node->getType() === FileInfo::TYPE_FOLDER) { 155 | if (!$recurse) { 156 | return; 157 | } 158 | // For normal inserts we probably get one event per node, but, when removing an ignore file, 159 | // we only get the folder passed here, so we recurse. 160 | try { 161 | /** @var Folder $node */ 162 | foreach ($node->getDirectoryListing() as $child) { 163 | $this->postInsert($child); 164 | } 165 | } catch (NotFoundException $e) { 166 | $this->logger->warning($e->getMessage(), ['exception' => $e]); 167 | } 168 | return; 169 | } 170 | 171 | if (!$this->allowedMimeType($node)) { 172 | return; 173 | } 174 | 175 | if (!$this->allowedPath($node)) { 176 | return; 177 | } 178 | 179 | $queueFile = new QueueFile(); 180 | if ($node->getMountPoint()->getNumericStorageId() === null) { 181 | return; 182 | } 183 | $queueFile->setStorageId($node->getMountPoint()->getNumericStorageId()); 184 | $queueFile->setRootId($node->getMountPoint()->getStorageRootId()); 185 | 186 | try { 187 | $queueFile->setFileId($node->getId()); 188 | } catch (InvalidPathException|NotFoundException $e) { 189 | $this->logger->warning($e->getMessage(), ['exception' => $e]); 190 | return; 191 | } 192 | 193 | $queueFile->setUpdate($update); 194 | try { 195 | $this->queue->insertIntoQueue($queueFile); 196 | } catch (Exception $e) { 197 | $this->logger->error('Failed to add file to queue', ['exception' => $e]); 198 | } 199 | } 200 | 201 | private function allowedMimeType(Node $file): bool { 202 | $mimeType = $file->getMimeType(); 203 | return in_array($mimeType, Application::MIMETYPES, true); 204 | } 205 | 206 | private function allowedPath(Node $file): bool { 207 | $path = $file->getPath(); 208 | return !preg_match('/^\/.+\/(files_versions|files_trashbin)\/.+/', $path, $matches); 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /lib/Listener/ShareListener.php: -------------------------------------------------------------------------------- 1 | 33 | */ 34 | class ShareListener implements IEventListener { 35 | 36 | public function __construct( 37 | private Logger $logger, 38 | private StorageService $storageService, 39 | private IManager $shareManager, 40 | private IRootFolder $rootFolder, 41 | private ActionService $actionService, 42 | private IGroupManager $groupManager, 43 | ) { 44 | } 45 | 46 | public function handle(Event $event): void { 47 | if ($event instanceof ShareCreatedEvent) { 48 | $share = $event->getShare(); 49 | $node = $share->getNode(); 50 | 51 | switch ($share->getShareType()) { 52 | case \OCP\Share\IShare::TYPE_USER: 53 | $userIds = [$share->getSharedWith()]; 54 | break; 55 | case \OCP\Share\IShare::TYPE_GROUP: 56 | $accessList = $this->shareManager->getAccessList($node, true, true); 57 | /** 58 | * @var string[] $userIds 59 | */ 60 | $userIds = array_keys($accessList['users']); 61 | break; 62 | default: 63 | return; 64 | } 65 | 66 | if ($node->getType() === FileInfo::TYPE_FOLDER) { 67 | $files = $this->storageService->getAllFilesInFolder($node); 68 | foreach ($files as $file) { 69 | if (!$file instanceof File) { 70 | continue; 71 | } 72 | $this->actionService->updateAccess( 73 | UpdateAccessOp::ALLOW, 74 | $userIds, 75 | ProviderConfigService::getSourceId($file->getId()), 76 | ); 77 | } 78 | } else { 79 | $this->actionService->updateAccess( 80 | UpdateAccessOp::ALLOW, 81 | $userIds, 82 | ProviderConfigService::getSourceId($node->getId()), 83 | ); 84 | } 85 | } 86 | 87 | if ($event instanceof ShareDeletedEvent) { 88 | $share = $event->getShare(); 89 | $node = $share->getNode(); 90 | 91 | // fileUserIds list is not fully accurate and doesn't update until the user(s) 92 | // in question logs in again, so we need to get the share access list 93 | // and the user(s) from whom the file was unshared with to update the access list, 94 | // keeping the access for the user(s) who still have access to the file through 95 | // file mounts. 96 | 97 | switch ($share->getShareType()) { 98 | case \OCP\Share\IShare::TYPE_USER: 99 | $unsharedWith = [$share->getSharedWith()]; 100 | break; 101 | case \OCP\Share\IShare::TYPE_GROUP: 102 | $unsharedWithGroup = $this->groupManager->get($share->getSharedWith()); 103 | if ($unsharedWithGroup === null) { 104 | $this->logger->warning('Could not find group with id ' . $share->getSharedWith()); 105 | return; 106 | } 107 | $unsharedWith = array_keys($unsharedWithGroup->getUsers()); 108 | break; 109 | default: 110 | return; 111 | } 112 | 113 | $shareAccessList = $this->shareManager->getAccessList($node, true, true); 114 | /** 115 | * @var string[] $shareUserIds 116 | */ 117 | $shareUserIds = array_keys($shareAccessList['users']); 118 | $fileUserIds = $this->storageService->getUsersForFileId($node->getId()); 119 | 120 | // the user(s) who have really lost access to the file and don't have access to it 121 | // through any other shares 122 | $reallyUnsharedWith = array_diff($unsharedWith, $shareUserIds); 123 | 124 | // the user(s) who have access to the file through file mounts, excluding the user(s) 125 | // who have really lost access to the file and are present in $fileUserIds list 126 | $realFileUserIds = array_diff($fileUserIds, $reallyUnsharedWith); 127 | // merge the share and file lists to get the final list of user(s) who have access to the file 128 | $userIds = array_values(array_unique(array_merge($realFileUserIds, $shareUserIds))); 129 | 130 | if ($node instanceof Folder) { 131 | $files = $this->storageService->getAllFilesInFolder($node); 132 | foreach ($files as $file) { 133 | $this->actionService->updateAccessDeclSource( 134 | $userIds, 135 | ProviderConfigService::getSourceId($file->getId()), 136 | ); 137 | } 138 | } else { 139 | if (!$this->allowedMimeType($node)) { 140 | return; 141 | } 142 | 143 | $fileRef = ProviderConfigService::getSourceId($node->getId()); 144 | $this->actionService->updateAccessDeclSource( 145 | $userIds, 146 | $fileRef, 147 | ); 148 | } 149 | } 150 | } 151 | 152 | private function allowedMimeType(Node $file): bool { 153 | $mimeType = $file->getMimeType(); 154 | return in_array($mimeType, Application::MIMETYPES, true); 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /lib/Listener/UserDeletedListener.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | class UserDeletedListener implements IEventListener { 23 | public function __construct( 24 | private ProviderConfigService $providerConfig, 25 | private ActionService $actionService, 26 | private Logger $logger, 27 | ) { 28 | } 29 | 30 | public function handle(Event $event): void { 31 | if (!($event instanceof UserDeletedEvent)) { 32 | return; 33 | } 34 | 35 | $this->actionService->deleteUser($event->getUid()); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /lib/Logger.php: -------------------------------------------------------------------------------- 1 | getLogFilepath(); 31 | $this->parentLogger = $logFactory->getCustomPsrLogger($logFilepath, 'file', 'Nextcloud Context Chat'); 32 | } 33 | 34 | public function getLogFilepath(): string { 35 | $default = $this->config->getSystemValue('datadirectory', \OC::$SERVERROOT . '/data') . '/context_chat.log'; 36 | // Legacy way was appconfig, now it's paralleled with the normal log config 37 | return $this->config->getAppValue(Application::APP_ID, 'logfile', $default); 38 | } 39 | 40 | public function emergency(Stringable|string $message, array $context = []): void { 41 | $this->parentLogger->emergency($message, $context); 42 | } 43 | 44 | public function alert(Stringable|string $message, array $context = []): void { 45 | $this->parentLogger->alert($message, $context); 46 | } 47 | 48 | public function critical(Stringable|string $message, array $context = []): void { 49 | $this->parentLogger->critical($message, $context); 50 | } 51 | 52 | public function error(Stringable|string $message, array $context = []): void { 53 | $this->parentLogger->critical($message, $context); 54 | } 55 | 56 | public function warning(Stringable|string $message, array $context = []): void { 57 | $this->parentLogger->critical($message, $context); 58 | } 59 | 60 | public function notice(Stringable|string $message, array $context = []): void { 61 | $this->parentLogger->critical($message, $context); 62 | } 63 | 64 | public function info(Stringable|string $message, array $context = []): void { 65 | $this->parentLogger->critical($message, $context); 66 | } 67 | 68 | public function debug(Stringable|string $message, array $context = []): void { 69 | // critical level is used here and at other places to not miss any message 70 | // from context chat when the server's log level is set to a higher level 71 | $this->parentLogger->critical($message, $context); 72 | } 73 | 74 | public function log(LogLevel $level, Stringable|string $message, array $context = []): void { 75 | $this->parentLogger->log($level, $message, $context); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /lib/Migration/Version001000000Date20231102094721.php: -------------------------------------------------------------------------------- 1 | hasTable('context_chat_queue')) { 41 | $table = $schema->createTable('context_chat_queue'); 42 | $table->addColumn('id', 'bigint', [ 43 | 'autoincrement' => true, 44 | 'notnull' => true, 45 | 'length' => 64, 46 | ]); 47 | $table->addColumn('file_id', 'bigint', [ 48 | 'notnull' => true, 49 | 'length' => 64, 50 | ]); 51 | $table->addColumn('storage_id', 'bigint', [ 52 | 'notnull' => false, 53 | 'length' => 64, 54 | ]); 55 | $table->addColumn('root_id', 'bigint', [ 56 | 'notnull' => false, 57 | 'length' => 64, 58 | ]); 59 | $table->addColumn('update', 'boolean', [ 60 | 'notnull' => false, 61 | ]); 62 | $table->setPrimaryKey(['id'], 'context_chat_q_id'); 63 | $table->addIndex(['file_id'], 'context_chat_q_file'); 64 | $table->addIndex(['storage_id', 'root_id'], 'context_chat_q_storage'); 65 | } 66 | 67 | return $schema; 68 | } 69 | 70 | /** 71 | * @param IOutput $output 72 | * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper` 73 | * @param array $options 74 | * 75 | * @return void 76 | */ 77 | public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options) { 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /lib/Migration/Version004000000Date20241206135634.php: -------------------------------------------------------------------------------- 1 | hasTable('context_chat_action_queue')) { 30 | $table = $schema->createTable('context_chat_action_queue'); 31 | 32 | $table->addColumn('id', Types::BIGINT, [ 33 | 'autoincrement' => true, 34 | 'notnull' => true, 35 | 'length' => 64, 36 | 'unsigned' => true, 37 | ]); 38 | $table->addColumn('type', Types::STRING, [ 39 | 'notnull' => true, 40 | 'length' => 128, 41 | ]); 42 | $table->addColumn('payload', Types::TEXT, [ 43 | 'notnull' => false, 44 | ]); 45 | 46 | $table->setPrimaryKey(['id'], 'ccc_action_queue_id'); 47 | } 48 | 49 | return $schema; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /lib/Migration/Version004000000Date20241217110041.php: -------------------------------------------------------------------------------- 1 | startProgress(10); 45 | foreach ([ 46 | SchedulerJob::class, 47 | StorageCrawlJob::class, 48 | IndexerJob::class, 49 | InitialContentImportJob::class, 50 | SubmitContentJob::class, 51 | ] as $className) { 52 | $this->jobList->remove($className); 53 | $output->advance(1); 54 | } 55 | 56 | /** @var ISchemaWrapper $schema */ 57 | $schema = $schemaClosure(); 58 | 59 | try { 60 | $qb = $this->db->getQueryBuilder(); 61 | if ($schema->hasTable('context_chat_delete_queue')) { 62 | $qb->delete('context_chat_delete_queue')->executeStatement(); 63 | } 64 | } catch (Exception $e) { 65 | $output->warning($e->getMessage()); 66 | } 67 | $output->advance(1); 68 | 69 | try { 70 | $qb = $this->db->getQueryBuilder(); 71 | if ($schema->hasTable('context_chat_action_queue')) { 72 | $qb->delete('context_chat_action_queue')->executeStatement(); 73 | } 74 | } catch (Exception $e) { 75 | $output->warning($e->getMessage()); 76 | } 77 | $output->advance(1); 78 | 79 | try { 80 | $qb = $this->db->getQueryBuilder(); 81 | if ($schema->hasTable('context_chat_content_queue')) { 82 | $qb->delete('context_chat_content_queue')->executeStatement(); 83 | } 84 | } catch (Exception $e) { 85 | $output->warning($e->getMessage()); 86 | } 87 | $output->advance(1); 88 | 89 | try { 90 | $qb = $this->db->getQueryBuilder(); 91 | if ($schema->hasTable('context_chat_queue')) { 92 | $qb->delete('context_chat_queue')->executeStatement(); 93 | } 94 | } catch (Exception $e) { 95 | $output->warning($e->getMessage()); 96 | } 97 | $output->advance(1); 98 | 99 | $this->config->setAppValue(Application::APP_ID, 'providers', ''); 100 | $output->advance(1); 101 | 102 | $this->jobList->add(SchedulerJob::class); 103 | 104 | $output->finishProgress(); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /lib/Migration/Version004002001Date20250410110041.php: -------------------------------------------------------------------------------- 1 | appConfig->deleteAppValue('background_jobs_diagnostics'); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /lib/Migration/Version1001Date20240130120627.php: -------------------------------------------------------------------------------- 1 | hasTable('context_chat_content_queue')) { 30 | $table = $schema->createTable('context_chat_content_queue'); 31 | 32 | $table->addColumn('id', Types::BIGINT, [ 33 | 'autoincrement' => true, 34 | 'notnull' => true, 35 | 'length' => 64, 36 | 'unsigned' => true, 37 | ]); 38 | $table->addColumn('item_id', Types::STRING, [ 39 | 'notnull' => true, 40 | 'length' => 512, 41 | ]); 42 | $table->addColumn('app_id', Types::STRING, [ 43 | 'notnull' => true, 44 | 'length' => 512, 45 | ]); 46 | $table->addColumn('provider_id', Types::STRING, [ 47 | 'notnull' => true, 48 | 'length' => 512, 49 | ]); 50 | $table->addColumn('title', Types::TEXT, [ 51 | 'notnull' => true, 52 | ]); 53 | $table->addColumn('content', Types::TEXT, [ 54 | 'notnull' => true, 55 | ]); 56 | $table->addColumn('document_type', Types::STRING, [ 57 | 'notnull' => true, 58 | 'length' => 512, 59 | ]); 60 | $table->addColumn('last_modified', Types::DATETIME, [ 61 | 'notnull' => true, 62 | ]); 63 | $table->addColumn('users', Types::TEXT, [ 64 | 'notnull' => true, 65 | ]); 66 | 67 | $table->setPrimaryKey(['id'], 'ccc_queue_id'); 68 | } 69 | 70 | return $schema; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /lib/Migration/Version2002Date20240619004215.php: -------------------------------------------------------------------------------- 1 | hasTable('context_chat_delete_queue')) { 30 | $table = $schema->createTable('context_chat_delete_queue'); 31 | 32 | $table->addColumn('id', Types::BIGINT, [ 33 | 'autoincrement' => true, 34 | 'notnull' => true, 35 | 'length' => 64, 36 | 'unsigned' => true, 37 | ]); 38 | $table->addColumn('type', Types::STRING, [ 39 | 'notnull' => true, 40 | 'length' => 128, 41 | ]); 42 | $table->addColumn('user_id', Types::STRING, [ 43 | 'notnull' => true, 44 | 'length' => 512, 45 | ]); 46 | $table->addColumn('payload', Types::STRING, [ 47 | 'notnull' => true, 48 | 'length' => 512, 49 | ]); 50 | 51 | $table->setPrimaryKey(['id'], 'ccc_delete_queue_id'); 52 | } 53 | 54 | return $schema; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /lib/Migration/Version4000Date20241108004215.php: -------------------------------------------------------------------------------- 1 | startProgress(10); 44 | foreach ([ 45 | SchedulerJob::class, 46 | StorageCrawlJob::class, 47 | IndexerJob::class, 48 | InitialContentImportJob::class, 49 | SubmitContentJob::class, 50 | ] as $className) { 51 | $this->jobList->remove($className); 52 | $output->advance(1); 53 | } 54 | 55 | 56 | try { 57 | $qb = $this->db->getQueryBuilder(); 58 | $qb->delete('context_chat_delete_queue')->executeStatement(); 59 | } catch (Exception $e) { 60 | $output->warning($e->getMessage()); 61 | } 62 | $output->advance(1); 63 | 64 | try { 65 | $qb = $this->db->getQueryBuilder(); 66 | $qb->delete('context_chat_content_queue')->executeStatement(); 67 | } catch (Exception $e) { 68 | $output->warning($e->getMessage()); 69 | } 70 | $output->advance(1); 71 | 72 | try { 73 | $qb = $this->db->getQueryBuilder(); 74 | $qb->delete('context_chat_queue')->executeStatement(); 75 | } catch (Exception $e) { 76 | $output->warning($e->getMessage()); 77 | } 78 | $output->advance(1); 79 | 80 | $this->config->setAppValue(Application::APP_ID, 'providers', ''); 81 | $output->advance(1); 82 | 83 | $this->jobList->add(SchedulerJob::class); 84 | 85 | $output->finishProgress(); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /lib/Migration/Version4000Date20241202141755.php: -------------------------------------------------------------------------------- 1 | hasTable('context_chat_delete_queue')) { 29 | $schema->dropTable('context_chat_delete_queue'); 30 | } 31 | 32 | return $schema; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /lib/Public/ContentItem.php: -------------------------------------------------------------------------------- 1 | appConfig->getValueInt(Application::APP_ID, 'installed_time', 0, false) === 0) { 38 | $this->logger->info('Setting up Context Chat for the first time'); 39 | $this->appConfig->setValueInt(Application::APP_ID, 'installed_time', time(), false); 40 | } 41 | 42 | // todo: migrate to IAppConfig 43 | $providerConfigService = new ProviderConfigService($this->config); 44 | /** @psalm-suppress ArgumentTypeCoercion, UndefinedClass */ 45 | $providerConfigService->updateProvider('files', 'default', '', true); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /lib/Service/ActionService.php: -------------------------------------------------------------------------------- 1 | setType($type); 38 | $item->setPayload($payload); 39 | 40 | // do not catch DB exceptions 41 | $this->actionMapper->insertIntoQueue($item); 42 | 43 | if (!$this->jobList->has(ActionJob::class, null)) { 44 | $this->jobList->add(ActionJob::class, null); 45 | } 46 | } 47 | 48 | /** 49 | * @param string[] $sourceIds 50 | * @return void 51 | */ 52 | public function deleteSources(string ...$sourceIds): void { 53 | // batch sourceIds into self::BATCH_SIZE chunks 54 | $batches = array_chunk($sourceIds, self::BATCH_SIZE); 55 | 56 | foreach ($batches as $batch) { 57 | $payload = json_encode(['sourceIds' => $batch]); 58 | if ($payload === false) { 59 | $this->logger->warning('Failed to json_encode sourceIds for deletion', ['sourceIds' => $batch]); 60 | continue; 61 | } 62 | $this->scheduleAction(ActionType::DELETE_SOURCE_IDS, $payload); 63 | } 64 | } 65 | 66 | /** 67 | * @param string $providerKey 68 | * @return void 69 | */ 70 | public function deleteProvider(string $providerKey): void { 71 | $payload = json_encode(['providerId' => $providerKey]); 72 | if ($payload === false) { 73 | $this->logger->warning('Failed to json_encode providerId for deletion', ['providerId' => $providerKey]); 74 | return; 75 | } 76 | $this->scheduleAction(ActionType::DELETE_PROVIDER_ID, $payload); 77 | } 78 | 79 | /** 80 | * @param string $userId 81 | * @return void 82 | */ 83 | public function deleteUser(string $userId): void { 84 | $payload = json_encode(['userId' => $userId]); 85 | if ($payload === false) { 86 | $this->logger->warning('Failed to json_encode userId for deletion', ['userId' => $userId]); 87 | return; 88 | } 89 | $this->scheduleAction(ActionType::DELETE_USER_ID, $payload); 90 | } 91 | 92 | /** 93 | * @param UpdateAccessOp::* $op 94 | * @param string[] $userIds 95 | * @param string $sourceId 96 | * @return void 97 | */ 98 | public function updateAccess(string $op, array $userIds, string $sourceId): void { 99 | if (count($userIds) === 0) { 100 | $this->logger->warning('userIds array is empty, ignoring this update', ['sourceId' => $sourceId]); 101 | return; 102 | } 103 | $payload = json_encode(['op' => $op, 'userIds' => $userIds, 'sourceId' => $sourceId]); 104 | if ($payload === false) { 105 | $this->logger->warning('Failed to json_encode access update for source', ['op' => $op, 'sourceId' => $sourceId]); 106 | return; 107 | } 108 | $this->scheduleAction(ActionType::UPDATE_ACCESS_SOURCE_ID, $payload); 109 | } 110 | 111 | /** 112 | * @param UpdateAccessOp::* $op 113 | * @param string[] $userIds 114 | * @param string $providerId 115 | * @return void 116 | */ 117 | public function updateAccessProvider(string $op, array $userIds, string $providerId): void { 118 | if (count($userIds) === 0) { 119 | $this->logger->warning('userIds array is empty, ignoring this update', ['sourceId' => $providerId]); 120 | return; 121 | } 122 | $payload = json_encode(['op' => $op, 'userIds' => $userIds, 'providerId' => $providerId]); 123 | if ($payload === false) { 124 | $this->logger->warning('Failed to json_encode access update for provider', ['op' => $op, 'providerId' => $providerId]); 125 | return; 126 | } 127 | $this->scheduleAction(ActionType::UPDATE_ACCESS_PROVIDER_ID, $payload); 128 | } 129 | 130 | /** 131 | * @param string[] $userIds 132 | * @param string $sourceId 133 | * @return void 134 | */ 135 | public function updateAccessDeclSource(array $userIds, string $sourceId): void { 136 | if (count($userIds) === 0) { 137 | $this->logger->warning('userIds array is empty, ignoring this update', ['sourceId' => $sourceId]); 138 | return; 139 | } 140 | $payload = json_encode(['userIds' => $userIds, 'sourceId' => $sourceId]); 141 | if ($payload === false) { 142 | $this->logger->warning('Failed to json_encode access update declarative for source', ['sourceId' => $sourceId]); 143 | return; 144 | } 145 | $this->scheduleAction(ActionType::UPDATE_ACCESS_DECL_SOURCE_ID, $payload); 146 | } 147 | 148 | /** 149 | * @throws \OCP\DB\Exception 150 | */ 151 | public function count(): int { 152 | return $this->actionMapper->count(); 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /lib/Service/DiagnosticService.php: -------------------------------------------------------------------------------- 1 | logger->info('Background jobs: ' . $class . ' ' . $id . ' triggered'); 29 | } 30 | 31 | /** 32 | * @param string $class 33 | * @param int $id 34 | * @return void 35 | */ 36 | public function sendJobStart(string $class, int $id): void { 37 | $this->logger->info('Background jobs: ' . $class . ' ' . $id . ' started'); 38 | } 39 | 40 | /** 41 | * @param string $class 42 | * @param int $id 43 | * @return void 44 | */ 45 | public function sendJobEnd(string $class, int $id): void { 46 | $this->logger->info('Background jobs: ' . $class . ' ' . $id . ' ended'); 47 | } 48 | 49 | /** 50 | * @param string $class 51 | * @param int $id 52 | * @return void 53 | */ 54 | public function sendHeartbeat(string $class, int $id): void { 55 | } 56 | 57 | /** 58 | * @param int $count 59 | * @return void 60 | */ 61 | public function sendIndexedFiles(int $count): void { 62 | $this->logger->info('Indexed ' . $count . ' files'); 63 | // We use numericToNumber to fall back to float in case int is too small 64 | $this->appConfig->setAppValueString( 65 | 'indexed_files_count', 66 | (string)Util::numericToNumber( 67 | floatval($count) + floatval(Util::numericToNumber($this->appConfig->getAppValueString('indexed_files_count', '0', false))) 68 | ), 69 | false, 70 | ); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /lib/Service/MetadataService.php: -------------------------------------------------------------------------------- 1 | 39 | */ 40 | public function getEnrichedProviders(): array { 41 | $this->contentManager->collectAllContentProviders(); 42 | $providers = $this->providerConfig->getProviders(); 43 | $sanitizedProviders = []; 44 | 45 | foreach ($providers as $providerKey => $metadata) { 46 | // providerKey ($appId__$providerId) 47 | /** @var string[] */ 48 | $providerValues = explode('__', $providerKey, 2); 49 | 50 | if (count($providerValues) !== 2) { 51 | $this->logger->info("Invalid provider key $providerKey, skipping"); 52 | continue; 53 | } 54 | 55 | [$appId, $providerId] = $providerValues; 56 | 57 | $user = $this->userId === null ? null : $this->userManager->get($this->userId); 58 | if (!$this->appManager->isEnabledForUser($appId, $user)) { 59 | $this->logger->info("App $appId is not enabled for user {$this->userId}, skipping"); 60 | continue; 61 | } 62 | 63 | $appInfo = $this->appManager->getAppInfo($appId); 64 | if ($appInfo === null) { 65 | $this->logger->info("Could not get app info for $appId, skipping"); 66 | continue; 67 | } 68 | 69 | try { 70 | $icon = $this->urlGenerator->imagePath($appId, 'app-dark.svg'); 71 | } catch (\RuntimeException $e) { 72 | $this->logger->info("Could not get app image for $appId"); 73 | $icon = ''; 74 | } 75 | 76 | $appName = $appInfo['name'] ?? ucfirst($appId); 77 | 78 | $sanitizedProviders[$providerKey] = [ 79 | 'id' => $providerKey, 80 | 'label' => $appName . ' - ' . ucfirst($providerId), 81 | 'icon' => $icon, 82 | ]; 83 | } 84 | return $sanitizedProviders; 85 | } 86 | 87 | private function getIdFromSource(string $sourceId): string { 88 | if (!preg_match('/^[^: ]+__[^: ]+: (\d+)$/', $sourceId, $matches)) { 89 | throw new \InvalidArgumentException("Invalid source id $sourceId"); 90 | } 91 | return $matches[1]; 92 | } 93 | 94 | /** 95 | * For files 96 | * @return array{ id: string, label: string, icon: string, url: string } 97 | */ 98 | private function getMetadataObjectForId(string $userId, string $sourceId): array { 99 | $id = $this->getIdFromSource($sourceId); 100 | $userFolder = $this->rootFolder->getUserFolder($userId); 101 | $nodes = $userFolder->getById(intval($id)); 102 | if (count($nodes) < 1) { 103 | throw new \InvalidArgumentException("Invalid node id $id"); 104 | } 105 | 106 | $node = $nodes[0]; 107 | return [ 108 | 'id' => $sourceId, 109 | 'label' => $node->getName(), 110 | 'icon' => $node->getType() == FileInfo::TYPE_FOLDER 111 | // ? $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute('/apps/theming/img/core/filetypes/folder.svg') 112 | ? $this->urlGenerator->getAbsoluteURL($this->urlGenerator->imagePath('core', 'folder.svg')) 113 | : $this->urlGenerator->linkToRouteAbsolute('assistant.preview.getFileImage', ['id' => $id, 'x' => 24, 'y' => 24]), 114 | 'url' => $this->urlGenerator->linkToRouteAbsolute('files.View.showFile', ['fileid' => $id]), 115 | ]; 116 | } 117 | 118 | /** 119 | * @return list 120 | */ 121 | public function getEnrichedSources(string $userId, string ...$sources): array { 122 | $enrichedProviders = $this->getEnrichedProviders(); 123 | $enrichedSources = []; 124 | 125 | # for providers 126 | foreach ($sources as $source) { 127 | if (str_starts_with($source, ProviderConfigService::getDefaultProviderKey() . ': ')) { 128 | continue; 129 | } 130 | 131 | $providerKey = explode(': ', $source, 2)[0]; 132 | if (!array_key_exists($providerKey, $enrichedProviders)) { 133 | $this->logger->warning('Could not find content provider by key', ['providerKey' => $providerKey, 'enrichedProviders' => $enrichedProviders]); 134 | continue; 135 | } 136 | 137 | $provider = $enrichedProviders[$providerKey]; 138 | $providerConfig = $this->providerConfig->getProvider($providerKey); 139 | if ($providerConfig === null) { 140 | $this->logger->warning('Could not find provider by key', ['providerKey' => $providerKey]); 141 | continue; 142 | } 143 | 144 | try { 145 | /** @var IContentProvider */ 146 | $klass = Server::get($providerConfig['classString']); 147 | $itemId = $this->getIdFromSource($source); 148 | $url = $klass->getItemUrl($itemId); 149 | $provider['url'] = $url; 150 | $provider['label'] .= ' #' . $itemId; 151 | $enrichedSources[] = $provider; 152 | } catch (ContainerExceptionInterface|NotFoundExceptionInterface $e) { 153 | $this->logger->warning('Could not find content provider by class name', ['classString' => $providerConfig['classString'], 'exception' => $e]); 154 | continue; 155 | } 156 | } 157 | 158 | # for files 159 | foreach ($sources as $source) { 160 | if (!str_starts_with($source, ProviderConfigService::getDefaultProviderKey() . ': ')) { 161 | continue; 162 | } 163 | $enrichedSources[] = $this->getMetadataObjectForId($userId, $source); 164 | } 165 | 166 | return $enrichedSources; 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /lib/Service/ProviderConfigService.php: -------------------------------------------------------------------------------- 1 | */ 15 | 16 | class ProviderConfigService { 17 | public function __construct( 18 | private IConfig $config, 19 | ) { 20 | } 21 | 22 | public static function getSourceId(int|string $nodeId, ?string $providerId = null): string { 23 | return ($providerId ?? self::getDefaultProviderKey()) . ': ' . $nodeId; 24 | } 25 | 26 | public static function getDefaultProviderKey(): string { 27 | return ProviderConfigService::getConfigKey('files', 'default'); 28 | } 29 | 30 | public static function getConfigKey(string $appId, string $providerId): string { 31 | return $appId . '__' . $providerId; 32 | } 33 | 34 | /** 35 | * @param array $providers 36 | * @return bool 37 | */ 38 | private function validateProvidersArray(array $providers): bool { 39 | foreach ($providers as $providerId => $value) { 40 | if (!is_string($providerId) || $providerId === '' 41 | || !isset($value['isInitiated']) || !is_bool($value['isInitiated']) 42 | || !isset($value['classString']) || !is_string($value['classString']) 43 | ) { 44 | return false; 45 | } 46 | } 47 | return true; 48 | } 49 | 50 | /** 51 | * @return array{ isInitiated: bool, classString: string } | null 52 | */ 53 | public function getProvider(string $providerKey): ?array { 54 | $providers = $this->getProviders(); 55 | return $providers[$providerKey] ?? null; 56 | } 57 | 58 | /** 59 | * @return array 60 | */ 61 | public function getProviders(): array { 62 | $providers = []; 63 | $providersString = $this->config->getAppValue(Application::APP_ID, 'providers', ''); 64 | 65 | if ($providersString !== '') { 66 | $providers = json_decode($providersString, true); 67 | 68 | if ($providers === null || !$this->validateProvidersArray($providers)) { 69 | $providers = []; 70 | $this->config->setAppValue(Application::APP_ID, 'providers', ''); 71 | } 72 | } 73 | 74 | return $providers; 75 | } 76 | 77 | /** 78 | * @param string $appId 79 | * @param string $providerId 80 | * @param class-string $providerClass 81 | * @param bool $isInitiated 82 | */ 83 | public function updateProvider( 84 | string $appId, 85 | string $providerId, 86 | string $providerClass, 87 | bool $isInitiated = false, 88 | ): void { 89 | $providers = $this->getProviders(); 90 | $providers[self::getConfigKey($appId, $providerId)] = [ 91 | 'isInitiated' => $isInitiated, 92 | 'classString' => $providerClass, 93 | ]; 94 | $this->config->setAppValue(Application::APP_ID, 'providers', json_encode($providers)); 95 | } 96 | 97 | /** 98 | * @param string $appId 99 | * @param ?string $providerId 100 | */ 101 | public function removeProvider(string $appId, ?string $providerId = null): void { 102 | $providers = $this->getProviders(); 103 | 104 | if ($providerId !== null && isset($providers[self::getConfigKey($appId, $providerId)])) { 105 | unset($providers[self::getConfigKey($appId, $providerId)]); 106 | } elseif ($providerId === null) { 107 | foreach ($providers as $k => $v) { 108 | if (str_starts_with($k, self::getConfigKey($appId, ''))) { 109 | unset($providers[$k]); 110 | } 111 | } 112 | } 113 | 114 | $this->config->setAppValue(Application::APP_ID, 'providers', json_encode($providers)); 115 | } 116 | 117 | /** 118 | * @param string $appId 119 | * @param string $providerId 120 | * @return bool 121 | */ 122 | public function hasProvider(string $appId, string $providerId): bool { 123 | $providers = $this->getProviders(); 124 | return isset($providers[self::getConfigKey($appId, $providerId)]); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /lib/Service/QueueService.php: -------------------------------------------------------------------------------- 1 | queueMapper->existsQueueItem($file)) { 31 | return; 32 | } 33 | 34 | $this->queueMapper->insertIntoQueue($file); 35 | $this->scheduleJob($file); 36 | } 37 | 38 | /** 39 | * @param QueueFile $file 40 | * @return void 41 | */ 42 | public function scheduleJob(QueueFile $file): void { 43 | if (!$this->jobList->has(IndexerJob::class, [ 44 | 'storageId' => $file->getStorageId(), 45 | 'rootId' => $file->getRootId(), 46 | ])) { 47 | $this->jobList->add(IndexerJob::class, [ 48 | 'storageId' => $file->getStorageId(), 49 | 'rootId' => $file->getRootId(), 50 | ]); 51 | } 52 | } 53 | 54 | /** 55 | * @param int $storageId 56 | * @param int $rootId 57 | * @param int $batchSize 58 | * @return QueueFile[] 59 | * @throws \OCP\DB\Exception 60 | */ 61 | public function getFromQueue(int $storageId, int $rootId, int $batchSize): array { 62 | return $this->queueMapper->getFromQueue($storageId, $rootId, $batchSize); 63 | } 64 | 65 | public function existsQueueFileId(int $fileId): bool { 66 | $queueItem = new QueueFile(); 67 | $queueItem->setFileId($fileId); 68 | return $this->queueMapper->existsQueueItem($queueItem); 69 | } 70 | 71 | /** 72 | * @param QueueFile[] $files 73 | * @return void 74 | * @throws \OCP\DB\Exception 75 | */ 76 | public function removeFromQueue(array $files): void { 77 | $this->queueMapper->removeFromQueue($files); 78 | } 79 | 80 | public function clearQueue(): void { 81 | $this->queueMapper->clearQueue(); 82 | } 83 | 84 | /** 85 | * @throws \OCP\DB\Exception 86 | */ 87 | public function count(): int { 88 | return $this->queueMapper->count(); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /lib/Service/ScanService.php: -------------------------------------------------------------------------------- 1 | 34 | */ 35 | public function scanUserFiles(string $userId, array $mimeTypeFilter, ?string $directory = null): \Generator { 36 | if ($directory === null) { 37 | $userFolder = $this->root->getUserFolder($userId); 38 | } else { 39 | $userFolder = $this->root->getUserFolder($userId)->get($directory); 40 | } 41 | 42 | yield from ($this->scanDirectory($mimeTypeFilter, $userFolder)); 43 | return []; 44 | } 45 | 46 | /** 47 | * @param array $mimeTypeFilter 48 | * @param Folder $directory 49 | * @return \Generator 50 | */ 51 | public function scanDirectory(array $mimeTypeFilter, Folder $directory): \Generator { 52 | $maxSize = (float)$this->appConfig->getAppValueInt('indexing_max_size', Application::CC_MAX_SIZE); 53 | $sources = []; 54 | $size = 0.0; 55 | 56 | foreach ($directory->getDirectoryListing() as $node) { 57 | if ($node instanceof File) { 58 | $nodeSize = (float)$node->getSize(); 59 | 60 | if ($nodeSize > $maxSize) { 61 | $this->logger->warning('[ScanService] File too large to index', [ 62 | 'nodeSize' => $nodeSize, 63 | 'maxSize' => $maxSize, 64 | 'nodeId' => $node->getId(), 65 | 'path' => $node->getPath(), 66 | ]); 67 | continue; 68 | } 69 | 70 | if ($size + $nodeSize > $maxSize || count($sources) >= Application::CC_MAX_FILES) { 71 | $this->langRopeService->indexSources($sources); 72 | $sources = []; 73 | $size = 0.0; 74 | } 75 | 76 | $source = $this->getSourceFromFile($mimeTypeFilter, $node); 77 | if ($source === null) { 78 | continue; 79 | } 80 | 81 | $sources[] = $source; 82 | $size += $nodeSize; 83 | 84 | yield $source; 85 | continue; 86 | } 87 | } 88 | 89 | if (count($sources) > 0) { 90 | $this->langRopeService->indexSources($sources); 91 | } 92 | 93 | foreach ($directory->getDirectoryListing() as $node) { 94 | if ($node instanceof Folder) { 95 | yield from $this->scanDirectory($mimeTypeFilter, $node); 96 | } 97 | } 98 | 99 | return []; 100 | } 101 | 102 | public function getSourceFromFile(array $mimeTypeFilter, File $node): ?Source { 103 | if (!in_array($node->getMimeType(), $mimeTypeFilter)) { 104 | return null; 105 | } 106 | 107 | try { 108 | $fileHandle = $node->fopen('rb'); 109 | } catch (\Exception $e) { 110 | $this->logger->error('Could not open file ' . $node->getPath() . ' for reading: ' . $e->getMessage()); 111 | return null; 112 | } 113 | 114 | $providerKey = ProviderConfigService::getDefaultProviderKey(); 115 | $sourceId = ProviderConfigService::getSourceId($node->getId()); 116 | $userIds = $this->storageService->getUsersForFileId($node->getId()); 117 | $path = substr($node->getInternalPath(), 6); // remove 'files/' prefix 118 | return new Source( 119 | $userIds, 120 | $sourceId, 121 | $path, 122 | $fileHandle, 123 | $node->getMTime(), 124 | $node->getMimeType(), 125 | $providerKey, 126 | ); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /lib/Settings/AdminSection.php: -------------------------------------------------------------------------------- 1 | l = $l; 19 | $this->urlgen = $urlgen; 20 | } 21 | 22 | /** 23 | * returns the ID of the section. It is supposed to be a lower case string 24 | * 25 | * 26 | * @return string 27 | */ 28 | public function getID(): string { 29 | return 'context_chat'; 30 | } 31 | 32 | /** 33 | * returns the translated name as it should be displayed, e.g. 'LDAP / AD 34 | * integration'. Use the L10N service to translate it. 35 | * 36 | * @return string 37 | */ 38 | public function getName(): string { 39 | return $this->l->t('Context Chat'); 40 | } 41 | 42 | /** 43 | * @return string 44 | */ 45 | public function getIcon(): string { 46 | return $this->urlgen->imagePath('context_chat', 'app-dark.svg'); 47 | } 48 | 49 | /** 50 | * @return int whether the form should be rather on the top or bottom of the settings navigation. The sections are arranged in ascending order of the priority values. It is required to return a value between 0 and 99. 51 | */ 52 | public function getPriority(): int { 53 | return 80; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /lib/Settings/AdminSettings.php: -------------------------------------------------------------------------------- 1 | appConfig->getAppValueInt('installed_time', 0); 43 | if ($this->appConfig->getAppValueInt('last_indexed_time', 0) === 0) { 44 | $stats['initial_indexing_complete'] = false; 45 | } else { 46 | $stats['initial_indexing_complete'] = true; 47 | $stats['intial_indexing_completed_at'] = $this->appConfig->getAppValueInt('last_indexed_time', 0); 48 | } 49 | 50 | try { 51 | $stats['eligible_files_count'] = $this->storageService->countFiles(); 52 | } catch (Exception $e) { 53 | $this->logger->error($e->getMessage(), ['exception' => $e]); 54 | $stats['eligible_files_count'] = 0; 55 | } 56 | $stats['indexed_files_count'] = Util::numericToNumber($this->appConfig->getAppValueString('indexed_files_count', '0')); 57 | try { 58 | $stats['queued_actions_count'] = $this->actionService->count(); 59 | } catch (Exception $e) { 60 | $this->logger->error($e->getMessage(), ['exception' => $e]); 61 | $stats['queued_actions_count'] = 0; 62 | } 63 | try { 64 | $stats['vectordb_document_counts'] = $this->langRopeService->getIndexedDocumentsCounts(); 65 | $stats['backend_available'] = true; 66 | } catch (\RuntimeException $e) { 67 | $stats['backend_available'] = false; 68 | $stats['vectordb_document_counts'] = [ ProviderConfigService::getDefaultProviderKey() => 0 ]; 69 | } 70 | try { 71 | $queued_files_count = $this->queueService->count(); 72 | } catch (Exception $e) { 73 | $this->logger->error($e->getMessage(), ['exception' => $e]); 74 | $queued_files_count = 0; 75 | } 76 | try { 77 | $stats['queued_documents_counts'] = $this->contentQueue->count(); 78 | $stats['queued_documents_counts'][ProviderConfigService::getDefaultProviderKey()] = $queued_files_count; 79 | } catch (Exception $e) { 80 | $this->logger->error($e->getMessage(), ['exception' => $e]); 81 | $stats['queued_documents_counts'] = []; 82 | } 83 | 84 | $this->initialState->provideInitialState('stats', $stats); 85 | 86 | return new TemplateResponse('context_chat', 'admin'); 87 | } 88 | 89 | /** 90 | * @return string the section ID, e.g. 'sharing' 91 | */ 92 | public function getSection(): string { 93 | return 'context_chat'; 94 | } 95 | 96 | /** 97 | * @return int whether the form should be rather on the top or bottom of the admin section. The forms are arranged in ascending order of the priority values. It is required to return a value between 0 and 100. 98 | */ 99 | public function getPriority(): int { 100 | return 50; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /lib/TaskProcessing/ContextChatSearchProvider.php: -------------------------------------------------------------------------------- 1 | l10n->t('Nextcloud Assistant Context Chat Search Provider'); 36 | } 37 | 38 | public function getTaskTypeId(): string { 39 | return ContextChatSearchTaskType::ID; 40 | } 41 | 42 | public function getExpectedRuntime(): int { 43 | return 120; 44 | } 45 | 46 | public function getInputShapeEnumValues(): array { 47 | return []; 48 | } 49 | 50 | public function getInputShapeDefaults(): array { 51 | return [ 52 | 'limit' => 10, 53 | ]; 54 | } 55 | 56 | public function getOptionalInputShape(): array { 57 | return []; 58 | } 59 | 60 | public function getOptionalInputShapeEnumValues(): array { 61 | return []; 62 | } 63 | 64 | public function getOptionalInputShapeDefaults(): array { 65 | return []; 66 | } 67 | 68 | public function getOutputShapeEnumValues(): array { 69 | return []; 70 | } 71 | 72 | public function getOptionalOutputShape(): array { 73 | return []; 74 | } 75 | 76 | public function getOptionalOutputShapeEnumValues(): array { 77 | return []; 78 | } 79 | 80 | /** 81 | * @inheritDoc 82 | * @return array{sources: list} 83 | * @throws \RuntimeException 84 | */ 85 | public function process(?string $userId, array $input, callable $reportProgress): array { 86 | if ($userId === null) { 87 | throw new \RuntimeException('User ID is required to process the prompt.'); 88 | } 89 | 90 | if (!isset($input['prompt']) || !is_string($input['prompt'])) { 91 | throw new \RuntimeException('Invalid input, expected "prompt" key with string value'); 92 | } 93 | 94 | if (!isset($input['limit']) || !is_numeric($input['limit'])) { 95 | throw new \RuntimeException('Invalid input, expected "limit" key with number value'); 96 | } 97 | $limit = (int)$input['limit']; 98 | 99 | if ( 100 | !isset($input['scopeType']) || !is_string($input['scopeType']) 101 | || !isset($input['scopeList']) || !is_array($input['scopeList']) 102 | || !isset($input['scopeListMeta']) || !is_string($input['scopeListMeta']) 103 | ) { 104 | throw new \RuntimeException('Invalid input, expected "scopeType" key with string value, "scopeList" key with array value and "scopeListMeta" key with string value'); 105 | } 106 | 107 | try { 108 | ScopeType::validate($input['scopeType']); 109 | } catch (\InvalidArgumentException $e) { 110 | throw new \RuntimeException($e->getMessage(), intval($e->getCode()), $e); 111 | } 112 | if ($input['scopeType'] === ScopeType::SOURCE) { 113 | throw new \InvalidArgumentException('Invalid scope type, source cannot be used to search'); 114 | } 115 | 116 | // unscoped query 117 | if ($input['scopeType'] === ScopeType::NONE) { 118 | $response = $this->langRopeService->docSearch( 119 | $userId, 120 | $input['prompt'], 121 | null, 122 | null, 123 | $limit, 124 | ); 125 | if (isset($response['error'])) { 126 | throw new \RuntimeException('No result in ContextChat response. ' . $response['error']); 127 | } 128 | return $this->processResponse($userId, $response); 129 | } 130 | 131 | // scoped query 132 | $scopeList = array_unique($input['scopeList']); 133 | if (count($scopeList) === 0) { 134 | throw new \RuntimeException('Empty scope list provided, use unscoped query instead'); 135 | } 136 | 137 | if ($input['scopeType'] === ScopeType::PROVIDER) { 138 | /** @var array $scopeList */ 139 | $processedScopes = $scopeList; 140 | $this->logger->debug('No need to index sources, querying ContextChat', ['scopeType' => $input['scopeType'], 'scopeList' => $processedScopes]); 141 | } else { 142 | // this should never happen 143 | throw new \InvalidArgumentException('Invalid scope type'); 144 | } 145 | 146 | if (count($processedScopes) === 0) { 147 | throw new \RuntimeException('No supported sources found in the scope list, extend the list or use unscoped query instead'); 148 | } 149 | 150 | $response = $this->langRopeService->docSearch( 151 | $userId, 152 | $input['prompt'], 153 | $input['scopeType'], 154 | $processedScopes, 155 | $limit, 156 | ); 157 | 158 | return $this->processResponse($userId, $response); 159 | } 160 | 161 | /** 162 | * Validate and enrich sources JSON strings of the response 163 | * 164 | * @param string $userId 165 | * @param array $response 166 | * @return array{sources: list} 167 | * @throws \RuntimeException 168 | */ 169 | private function processResponse(string $userId, array $response): array { 170 | if (isset($response['error'])) { 171 | throw new \RuntimeException('Error received in ContextChat document search request: ' . $response['error']); 172 | } 173 | if (!array_is_list($response)) { 174 | throw new \RuntimeException('Invalid response from ContextChat, expected a list: ' . json_encode($response)); 175 | } 176 | 177 | if (count($response) === 0) { 178 | $this->logger->info('No sources found in the response', ['response' => $response]); 179 | return [ 180 | 'sources' => [], 181 | ]; 182 | } 183 | 184 | $sources = $response; 185 | $jsonSources = array_filter(array_map( 186 | fn ($source) => json_encode($source), 187 | $this->metadataService->getEnrichedSources( 188 | $userId, 189 | ...array_map( 190 | fn ($source) => $source['source_id'] ?? null, 191 | $sources, 192 | ), 193 | ), 194 | ), fn ($json) => is_string($json)); 195 | 196 | if (count($jsonSources) === 0) { 197 | $this->logger->warning('No sources could be enriched', ['sources' => $sources]); 198 | } elseif (count($jsonSources) !== count($sources)) { 199 | $this->logger->warning('Some sources could not be enriched', ['sources' => $sources, 'jsonSources' => $jsonSources]); 200 | } 201 | 202 | return [ 203 | 'sources' => $jsonSources, 204 | ]; 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /lib/TaskProcessing/ContextChatSearchTaskType.php: -------------------------------------------------------------------------------- 1 | l->t('Context Chat search'); 32 | } 33 | 34 | /** 35 | * @inheritDoc 36 | * @since 2.3.0 37 | */ 38 | public function getDescription(): string { 39 | return $this->l->t('Search with Context Chat.'); 40 | } 41 | 42 | /** 43 | * @return string 44 | * @since 2.3.0 45 | */ 46 | public function getId(): string { 47 | return self::ID; 48 | } 49 | 50 | /** 51 | * @return ShapeDescriptor[] 52 | * @since 2.3.0 53 | */ 54 | public function getInputShape(): array { 55 | return [ 56 | 'prompt' => new ShapeDescriptor( 57 | $this->l->t('Prompt'), 58 | $this->l->t('Search your documents, files and more'), 59 | EShapeType::Text, 60 | ), 61 | 'scopeType' => new ShapeDescriptor( 62 | $this->l->t('Scope type'), 63 | $this->l->t('none, provider'), 64 | EShapeType::Text, 65 | ), 66 | 'scopeList' => new ShapeDescriptor( 67 | $this->l->t('Scope list'), 68 | $this->l->t('list of providers'), 69 | EShapeType::ListOfTexts, 70 | ), 71 | 'scopeListMeta' => new ShapeDescriptor( 72 | $this->l->t('Scope list metadata'), 73 | $this->l->t('Required to nicely render the scope list in assistant'), 74 | EShapeType::Text, 75 | ), 76 | 'limit' => new ShapeDescriptor( 77 | $this->l->t('Max result number'), 78 | $this->l->t('Maximum number of results returned by Context Chat'), 79 | EShapeType::Number, 80 | ), 81 | ]; 82 | } 83 | 84 | /** 85 | * @return ShapeDescriptor[] 86 | * @since 2.3.0 87 | */ 88 | public function getOutputShape(): array { 89 | return [ 90 | // each string is a json encoded object 91 | // { id: string, label: string, icon: string, url: string } 92 | 'sources' => new ShapeDescriptor( 93 | $this->l->t('Sources'), 94 | $this->l->t('The sources that were found'), 95 | EShapeType::ListOfTexts, 96 | ), 97 | ]; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /lib/TaskProcessing/ContextChatTaskType.php: -------------------------------------------------------------------------------- 1 | l->t('Context Chat'); 32 | } 33 | 34 | /** 35 | * @inheritDoc 36 | * @since 2.3.0 37 | */ 38 | public function getDescription(): string { 39 | return $this->l->t('Ask a question about your data.'); 40 | } 41 | 42 | /** 43 | * @return string 44 | * @since 2.3.0 45 | */ 46 | public function getId(): string { 47 | return self::ID; 48 | } 49 | 50 | /** 51 | * @return ShapeDescriptor[] 52 | * @since 2.3.0 53 | */ 54 | public function getInputShape(): array { 55 | return [ 56 | 'prompt' => new ShapeDescriptor( 57 | $this->l->t('Prompt'), 58 | $this->l->t('Ask a question about your documents, files and more'), 59 | EShapeType::Text, 60 | ), 61 | 'scopeType' => new ShapeDescriptor( 62 | $this->l->t('Scope type'), 63 | $this->l->t('none, source, provider'), 64 | EShapeType::Text, 65 | ), 66 | 'scopeList' => new ShapeDescriptor( 67 | $this->l->t('Scope list'), 68 | $this->l->t('list of sources or providers'), 69 | EShapeType::ListOfTexts, 70 | ), 71 | 'scopeListMeta' => new ShapeDescriptor( 72 | $this->l->t('Scope list metadata'), 73 | $this->l->t('Required to nicely render the scope list in assistant'), 74 | EShapeType::Text, 75 | ), 76 | ]; 77 | } 78 | 79 | /** 80 | * @return ShapeDescriptor[] 81 | * @since 2.3.0 82 | */ 83 | public function getOutputShape(): array { 84 | return [ 85 | 'output' => new ShapeDescriptor( 86 | $this->l->t('Generated response'), 87 | $this->l->t('The text generated by the model'), 88 | EShapeType::Text, 89 | ), 90 | // each string is a json encoded object 91 | // { id: string, label: string, icon: string, url: string } 92 | 'sources' => new ShapeDescriptor( 93 | $this->l->t('Sources'), 94 | $this->l->t('The sources referenced to generate the above response'), 95 | EShapeType::ListOfTexts, 96 | ), 97 | ]; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /lib/Type/ActionType.php: -------------------------------------------------------------------------------- 1 | } 12 | public const DELETE_SOURCE_IDS = 'delete_source_ids'; 13 | // { providerId: string } 14 | public const DELETE_PROVIDER_ID = 'delete_provider_id'; 15 | // { userId: string } 16 | public const DELETE_USER_ID = 'delete_user_id'; 17 | // { op: string, userIds: array, sourceId: string } 18 | public const UPDATE_ACCESS_SOURCE_ID = 'update_access_source_id'; 19 | // { op: string, userIds: array, providerId: string } 20 | public const UPDATE_ACCESS_PROVIDER_ID = 'update_access_provider_id'; 21 | // { userIds: array, sourceId: string } 22 | public const UPDATE_ACCESS_DECL_SOURCE_ID = 'update_access_decl_source_id'; 23 | } 24 | -------------------------------------------------------------------------------- /lib/Type/ScopeType.php: -------------------------------------------------------------------------------- 1 | getConstants())) { 20 | throw new \InvalidArgumentException( 21 | "Invalid scope type: {$scopeType}, should be one of: [" . implode(', ', $relection->getConstants()) . ']' 22 | ); 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /lib/Type/Source.php: -------------------------------------------------------------------------------- 1 | /dev/null) 14 | composer=$(shell which composer 2> /dev/null) 15 | 16 | all: build 17 | 18 | .PHONY: build 19 | build: 20 | ifneq (,$(wildcard $(CURDIR)/composer.json)) 21 | make composer 22 | endif 23 | ifneq (,$(wildcard $(CURDIR)/package.json)) 24 | make npm 25 | endif 26 | 27 | .PHONY: dev 28 | dev: 29 | ifneq (,$(wildcard $(CURDIR)/composer.json)) 30 | make composer 31 | endif 32 | ifneq (,$(wildcard $(CURDIR)/package.json)) 33 | make npm-dev 34 | endif 35 | 36 | # Installs and updates the composer dependencies. If composer is not installed 37 | # a copy is fetched from the web 38 | .PHONY: composer 39 | composer: 40 | ifeq (, $(composer)) 41 | @echo "No composer command available, downloading a copy from the web" 42 | mkdir -p $(build_tools_directory) 43 | curl -sS https://getcomposer.org/installer | php 44 | mv composer.phar $(build_tools_directory) 45 | php $(build_tools_directory)/composer.phar install --prefer-dist 46 | else 47 | composer install --prefer-dist 48 | endif 49 | 50 | .PHONY: npm 51 | npm: 52 | $(npm) ci 53 | $(npm) run build 54 | 55 | .PHONY: npm-dev 56 | npm-dev: 57 | $(npm) ci 58 | $(npm) run dev 59 | 60 | clean: 61 | sudo rm -rf $(build_dir) 62 | sudo rm -rf $(sign_dir) 63 | 64 | appstore: clean 65 | mkdir -p $(sign_dir) 66 | mkdir -p $(build_dir) 67 | @rsync -a \ 68 | --exclude=.git \ 69 | --exclude=appinfo/signature.json \ 70 | --exclude=*.swp \ 71 | --exclude=build \ 72 | --exclude=.gitignore \ 73 | --exclude=.travis.yml \ 74 | --exclude=.scrutinizer.yml \ 75 | --exclude=CONTRIBUTING.md \ 76 | --exclude=composer.json \ 77 | --exclude=composer.lock \ 78 | --exclude=composer.phar \ 79 | --exclude=package.json \ 80 | --exclude=package-lock.json \ 81 | --exclude=js/node_modules \ 82 | --exclude=node_modules \ 83 | --exclude=/src \ 84 | --exclude=translationfiles \ 85 | --exclude=webpack.* \ 86 | --exclude=stylelint.config.js \ 87 | --exclude=.eslintrc.js \ 88 | --exclude=.github \ 89 | --exclude=.gitlab-ci.yml \ 90 | --exclude=crowdin.yml \ 91 | --exclude=tools \ 92 | --exclude=l10n/.tx \ 93 | --exclude=.tx \ 94 | --exclude=.l10nignore \ 95 | --exclude=l10n/l10n.pl \ 96 | --exclude=l10n/templates \ 97 | --exclude=l10n/*.sh \ 98 | --exclude=l10n/[a-z][a-z] \ 99 | --exclude=l10n/[a-z][a-z]_[A-Z][A-Z] \ 100 | --exclude=l10n/no-php \ 101 | --exclude=makefile \ 102 | --exclude=screenshots \ 103 | --exclude=phpunit*xml \ 104 | --exclude=tests \ 105 | --exclude=ci \ 106 | --exclude=vendor/bin \ 107 | $(project_dir) $(sign_dir)/$(app_name) 108 | @if [ -f $(cert_dir)/$(app_name).key ]; then \ 109 | sudo chown $(webserveruser) $(sign_dir)/$(app_name)/appinfo ;\ 110 | sudo -u $(webserveruser) php $(occ_dir)/occ integrity:sign-app --privateKey=$(cert_dir)/$(app_name).key --certificate=$(cert_dir)/$(app_name).crt --path=$(sign_dir)/$(app_name)/ ;\ 111 | sudo chown -R $(USER) $(sign_dir)/$(app_name)/appinfo ;\ 112 | else \ 113 | echo "!!! WARNING signature key not found" ;\ 114 | fi 115 | tar -czf $(build_dir)/$(app_name)-$(app_version).tar.gz \ 116 | -C $(sign_dir) $(app_name) 117 | @if [ -f $(cert_dir)/$(app_name).key ]; then \ 118 | echo NEXTCLOUD------------------------------------------ ;\ 119 | openssl dgst -sha512 -sign $(cert_dir)/$(app_name).key $(build_dir)/$(app_name)-$(app_version).tar.gz | openssl base64 | tee $(build_dir)/sign.txt ;\ 120 | fi 121 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "context_chat", 3 | "version": "1.0.0", 4 | "description": "ContextChat", 5 | "main": "index.js", 6 | "directories": { 7 | "test": "tests" 8 | }, 9 | "scripts": { 10 | "build": "NODE_ENV=production webpack --progress --config webpack.js", 11 | "dev": "NODE_ENV=development webpack --progress --config webpack.js", 12 | "watch": "NODE_ENV=development webpack --progress --watch --config webpack.js", 13 | "lint": "eslint --ext .js,.vue src", 14 | "lint:fix": "eslint --ext .js,.vue src --fix", 15 | "stylelint": "stylelint src/**/*.vue src/**/*.scss src/**/*.css", 16 | "stylelint:fix": "stylelint src/**/*.vue src/**/*.scss src/**/*.css --fix" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "https://github.com/nextcloud/context_chat" 21 | }, 22 | "keywords": [ 23 | "ai" 24 | ], 25 | "author": "Julien Veyssier", 26 | "license": "AGPL-3.0-or-later", 27 | "bugs": { 28 | "url": "https://github.com/nextcloud/context_chat/issues" 29 | }, 30 | "homepage": "https://github.com/nextcloud/context_chat", 31 | "browserslist": [ 32 | "extends @nextcloud/browserslist-config" 33 | ], 34 | "engines": { 35 | "node": "^20", 36 | "npm": "^10" 37 | }, 38 | "dependencies": { 39 | "@nextcloud/initial-state": "^2.2.0", 40 | "@nextcloud/vue": "8.x", 41 | "humanize-duration": "^3.32.1", 42 | "vue": "^2.7.12" 43 | }, 44 | "devDependencies": { 45 | "@nextcloud/babel-config": "^1.0.0", 46 | "@nextcloud/browserslist-config": "^3.0.0", 47 | "@nextcloud/eslint-config": "^8.0.0", 48 | "@nextcloud/stylelint-config": "^2.1.2", 49 | "@nextcloud/webpack-vue-config": "^6.0.0", 50 | "eslint-webpack-plugin": "^4.0.0", 51 | "stylelint-webpack-plugin": "^4.0.0" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /psalm.xml: -------------------------------------------------------------------------------- 1 | 2 | 12 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /screenshots/context_chat_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nextcloud/context_chat/dd359b8d1d1f33e25c55b4f5d242fae1917a7498/screenshots/context_chat_1.png -------------------------------------------------------------------------------- /screenshots/context_chat_1.png.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors 2 | SPDX-License-Identifier: AGPL-3.0-or-later 3 | -------------------------------------------------------------------------------- /screenshots/context_chat_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nextcloud/context_chat/dd359b8d1d1f33e25c55b4f5d242fae1917a7498/screenshots/context_chat_2.png -------------------------------------------------------------------------------- /screenshots/context_chat_2.png.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors 2 | SPDX-License-Identifier: AGPL-3.0-or-later 3 | -------------------------------------------------------------------------------- /screenshots/context_chat_4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nextcloud/context_chat/dd359b8d1d1f33e25c55b4f5d242fae1917a7498/screenshots/context_chat_4.png -------------------------------------------------------------------------------- /screenshots/context_chat_4.png.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors 2 | SPDX-License-Identifier: AGPL-3.0-or-later 3 | -------------------------------------------------------------------------------- /screenshots/context_chat_5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nextcloud/context_chat/dd359b8d1d1f33e25c55b4f5d242fae1917a7498/screenshots/context_chat_5.png -------------------------------------------------------------------------------- /screenshots/context_chat_5.png.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors 2 | SPDX-License-Identifier: AGPL-3.0-or-later 3 | -------------------------------------------------------------------------------- /src/admin.js: -------------------------------------------------------------------------------- 1 | /** 2 | * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors 3 | * SPDX-License-Identifier: AGPL-3.0-or-later 4 | */ 5 | import Vue from 'vue' 6 | import App from './components/ViewAdmin.vue' 7 | import AppGlobal from './mixins/AppGlobal.js' 8 | 9 | Vue.mixin(AppGlobal) 10 | 11 | global.ContextChat = new Vue({ 12 | el: '#context_chat', 13 | render: h => h(App), 14 | }) 15 | -------------------------------------------------------------------------------- /src/components/ViewAdmin.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 53 | 54 | 103 | 133 | -------------------------------------------------------------------------------- /src/mixins/AppGlobal.js: -------------------------------------------------------------------------------- 1 | /** 2 | * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors 3 | * SPDX-License-Identifier: AGPL-3.0-or-later 4 | */ 5 | export default { 6 | methods: { 7 | t, 8 | n, 9 | }, 10 | computed: { 11 | colorPrimary() { 12 | return getComputedStyle(document.documentElement).getPropertyValue('--color-primary') 13 | }, 14 | colorPrimaryLight() { 15 | return getComputedStyle(document.documentElement).getPropertyValue('--color-primary-light') 16 | }, 17 | colorPrimaryElement() { 18 | return getComputedStyle(document.documentElement).getPropertyValue('--color-primary-element') 19 | }, 20 | colorPrimaryElementLight() { 21 | return getComputedStyle(document.documentElement).getPropertyValue('--color-primary-element-light') 22 | }, 23 | colorPrimaryText() { 24 | return getComputedStyle(document.documentElement).getPropertyValue('--color-primary-text') 25 | }, 26 | colorMainText() { 27 | return getComputedStyle(document.documentElement).getPropertyValue('--color-main-text') 28 | }, 29 | colorMainBackground() { 30 | return getComputedStyle(document.documentElement).getPropertyValue('--color-main-background') 31 | }, 32 | colorPlaceholderDark() { 33 | return getComputedStyle(document.documentElement).getPropertyValue('--color-placeholder-dark') 34 | }, 35 | }, 36 | } 37 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors 3 | * SPDX-License-Identifier: AGPL-3.0-or-later 4 | */ 5 | 6 | let mytimer = 0 7 | export function delay(callback, ms) { 8 | return function() { 9 | const context = this 10 | const args = arguments 11 | clearTimeout(mytimer) 12 | mytimer = setTimeout(function() { 13 | callback.apply(context, args) 14 | }, ms || 0) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /stubs/appapi-public-functions.php: -------------------------------------------------------------------------------- 1 | 9 |
10 | -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 | loadApp(Application::APP_ID); 16 | OC_Hook::clear(); 17 | -------------------------------------------------------------------------------- /tests/integration/ContentManagerTest.php: -------------------------------------------------------------------------------- 1 | jobList = Server::get(IJobList::class); 53 | $this->logger = Server::get(LoggerInterface::class); 54 | 55 | $this->mapper = $this->createMock(QueueContentItemMapper::class); 56 | $this->providerConfig = $this->createMock(ProviderConfigService::class); 57 | $this->actionService = $this->createMock(ActionService::class); 58 | 59 | // new dispatcher for each test 60 | $this->dispatcher = new SymfonyDispatcher(); 61 | $this->serverContainer = Server::get(IServerContainer::class); 62 | $this->eventDispatcher = new \OC\EventDispatcher\EventDispatcher( 63 | $this->dispatcher, 64 | $this->serverContainer, 65 | $this->logger, 66 | ); 67 | 68 | $this->providerConfig 69 | ->method('getProviders') 70 | ->willReturn([ 71 | ProviderConfigService::getDefaultProviderKey() => [ 72 | 'isInitiated' => true, 73 | 'classString' => '', 74 | ], 75 | ProviderConfigService::getConfigKey(Application::APP_ID, 'test-provider') => [ 76 | 'isInitiated' => false, 77 | 'classString' => static::$providerClass, 78 | ], 79 | ]); 80 | 81 | // $this->overwriteService(ProviderConfigService::class, $this->providerConfig); 82 | 83 | // using this app's app id to pass the check that the app is enabled for the user 84 | $providerObj = new ContentProvider(Application::APP_ID, 'test-provider', function () { 85 | // $this->initCalled = true; 86 | }); 87 | $providerClass = get_class($providerObj); 88 | 89 | \OC::$server->registerService($providerClass, function () use ($providerObj) { 90 | return $providerObj; 91 | }); 92 | 93 | $this->contentManager = new ContentManager( 94 | $this->jobList, 95 | $this->providerConfig, 96 | $this->mapper, 97 | $this->actionService, 98 | Server::get(Logger::class), 99 | $this->eventDispatcher, 100 | ); 101 | } 102 | 103 | public static function dataBank(): array { 104 | return [ 105 | /* [$classString, $appId, $providerId, $registrationSuccessful] */ 106 | [ static::$providerClass, Application::APP_ID, 'test-provider', true ], 107 | [ 'invalid', Application::APP_ID, 'test-provider', false ], 108 | ]; 109 | } 110 | 111 | /** 112 | * @param class-string $providerClass 113 | * @param string $appId 114 | * @param string $providerId 115 | * @param bool $registrationSuccessful 116 | * @dataProvider dataBank 117 | */ 118 | public function testRegisterContentProvider( 119 | string $providerClass, 120 | string $appId, 121 | string $providerId, 122 | bool $registrationSuccessful, 123 | ): void { 124 | $this->providerConfig 125 | ->expects($this->once()) 126 | ->method('hasProvider') 127 | ->with($appId, $providerId) 128 | ->willReturn(false); 129 | 130 | $this->providerConfig 131 | ->expects($registrationSuccessful ? $this->once() : $this->never()) 132 | ->method('updateProvider') 133 | ->with($appId, $providerId, $providerClass); 134 | 135 | // register the listener for the event 136 | $this->eventDispatcher->addListener( 137 | ContentProviderRegisterEvent::class, 138 | function (ContentProviderRegisterEvent $event) use ($appId, $providerId, $providerClass) { 139 | if (!($event instanceof ContentProviderRegisterEvent)) { 140 | return; 141 | } 142 | $event->registerContentProvider($appId, $providerId, $providerClass); 143 | }, 144 | ); 145 | 146 | // sample action that should trigger the registration 147 | $this->contentManager->removeAllContentForUsers($appId, $providerId, ['user1', 'user2']); 148 | 149 | $jobsIter = $this->jobList->getJobsIterator(InitialContentImportJob::class, 1, 0); 150 | if ($registrationSuccessful) { 151 | $this->assertNotNull($jobsIter); 152 | $this->jobList->remove(InitialContentImportJob::class, $providerClass); 153 | } 154 | } 155 | 156 | public function testSubmitContent(): void { 157 | $appId = 'test'; 158 | $items = [ 159 | new ContentItem( 160 | 'item-id', 161 | 'provider-id', 162 | 'title', 163 | 'content', 164 | 'email-file', 165 | new DateTime(), 166 | ['user1', 'user2'], 167 | ), 168 | ]; 169 | 170 | $this->mapper 171 | ->expects($this->once()) 172 | ->method('insert'); 173 | 174 | $this->jobList->remove(SubmitContentJob::class, null); 175 | $this->assertFalse($this->jobList->has(SubmitContentJob::class, null)); 176 | 177 | $this->contentManager->submitContent($appId, $items); 178 | 179 | $this->assertTrue($this->jobList->has(SubmitContentJob::class, null)); 180 | $this->jobList->remove(SubmitContentJob::class, null); 181 | } 182 | } 183 | 184 | class ContentProvider implements IContentProvider { 185 | public function __construct( 186 | private string $appId, 187 | private string $providerId, 188 | private $callback, 189 | ) { 190 | } 191 | 192 | public function getId(): string { 193 | return $this->providerId; 194 | } 195 | 196 | public function getAppId(): string { 197 | return $this->appId; 198 | } 199 | 200 | public function getItemUrl(string $id): string { 201 | return 'https://nextcloud.local/test-provider/' . $id; 202 | } 203 | 204 | public function triggerInitialImport(): void { 205 | ($this->callback)(); 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /tests/integration/ProviderConfigServiceTest.php: -------------------------------------------------------------------------------- 1 | config = $this->createMock(IConfig::class); 26 | $this->providerConfig = new ProviderConfigService($this->config); 27 | } 28 | 29 | public function testGetConfigKey(): void { 30 | $appId = 'app'; 31 | $providerId = 'provider'; 32 | $expected = $appId . '__' . $providerId; 33 | 34 | $this->assertEquals($expected, ProviderConfigService::getConfigKey($appId, $providerId)); 35 | } 36 | 37 | public static function dataBank(): array { 38 | $validData = [ 39 | ProviderConfigService::getConfigKey('app1', 'provider1') => [ 40 | 'isInitiated' => true, 41 | 'classString' => 'class1', 42 | ], 43 | ProviderConfigService::getConfigKey('app1', 'provider2') => [ 44 | 'isInitiated' => false, 45 | 'classString' => 'class2', 46 | ], 47 | ]; 48 | 49 | return [ 50 | [ json_encode($validData), $validData ], 51 | [ '', [] ], 52 | [ 'invalid', [] ], 53 | ]; 54 | } 55 | 56 | /** 57 | * @dataProvider dataBank 58 | * @param string $returnVal 59 | * @param array $providers 60 | * @return void 61 | */ 62 | public function testGetProviders(string $returnVal, array $providers): void { 63 | $this->config 64 | ->expects($this->once()) 65 | ->method('getAppValue') 66 | ->with(Application::APP_ID, 'providers') 67 | ->willReturn($returnVal); 68 | 69 | $this->assertEquals($providers, $this->providerConfig->getProviders()); 70 | } 71 | 72 | /** 73 | * @dataProvider dataBank 74 | * @param string $returnVal 75 | * @param array $providers 76 | * @return void 77 | */ 78 | public function testUpdateProvider(string $returnVal, array $providers): void { 79 | $appId = 'app'; 80 | $providerId = 'provider'; 81 | $providerClass = 'class'; 82 | $isInitiated = true; 83 | 84 | $newProvider = [ 85 | ProviderConfigService::getConfigKey($appId, $providerId) => [ 86 | 'isInitiated' => $isInitiated, 87 | 'classString' => $providerClass, 88 | ], 89 | ]; 90 | $extendedProviders = array_merge($providers, $newProvider); 91 | 92 | $setProvidersValue = match ($returnVal) { 93 | '', 'invalid' => json_encode($newProvider), 94 | default => json_encode($extendedProviders), 95 | }; 96 | 97 | $this->config 98 | ->expects($this->once()) 99 | ->method('getAppValue') 100 | ->with(Application::APP_ID, 'providers') 101 | ->willReturn($returnVal); 102 | 103 | $this->config 104 | ->expects($returnVal === 'invalid' ? $this->exactly(2) : $this->once()) 105 | ->method('setAppValue') 106 | ->with(Application::APP_ID, 'providers', $this->logicalOr($this->equalTo(''), $this->equalTo($setProvidersValue))); 107 | 108 | $this->providerConfig->updateProvider($appId, $providerId, $providerClass, $isInitiated); 109 | } 110 | 111 | /** 112 | * @dataProvider dataBank 113 | * @param string $returnVal 114 | * @param array $providers 115 | * @return void 116 | */ 117 | public function testRemoveProvider(string $returnVal, array $providers): void { 118 | $appId = 'app1'; 119 | $providerId = 'provider1'; 120 | $identifier = ProviderConfigService::getConfigKey($appId, $providerId); 121 | 122 | $this->config 123 | ->expects($this->once()) 124 | ->method('getAppValue') 125 | ->with(Application::APP_ID, 'providers') 126 | ->willReturn($returnVal); 127 | 128 | if (isset($providers[$identifier])) { 129 | unset($providers[$identifier]); 130 | } 131 | 132 | $this->config 133 | ->expects($returnVal === 'invalid' ? $this->exactly(2) : $this->once()) 134 | ->method('setAppValue') 135 | ->with(Application::APP_ID, 'providers', $this->logicalOr( 136 | $this->equalTo(''), 137 | $this->equalTo(json_encode($providers)) 138 | )); 139 | 140 | $this->providerConfig->removeProvider($appId, $providerId); 141 | } 142 | 143 | /** 144 | * @dataProvider dataBank 145 | * @param string $returnVal 146 | * @param array $providers 147 | * @return void 148 | */ 149 | public function testHasProvider(string $returnVal, array $providers): void { 150 | $appId = 'app1'; 151 | $providerId = 'provider1'; 152 | 153 | $this->config 154 | ->expects($this->once()) 155 | ->method('getAppValue') 156 | ->with(Application::APP_ID, 'providers') 157 | ->willReturn($returnVal); 158 | 159 | $expected = match ($returnVal) { 160 | '', 'invalid' => false, 161 | default => true, 162 | }; 163 | 164 | $this->assertEquals($expected, $this->providerConfig->hasProvider($appId, $providerId)); 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /tests/phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | . 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | ../appinfo 22 | ../lib 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /tests/psalm-baseline.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /webpack.js: -------------------------------------------------------------------------------- 1 | /** 2 | * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors 3 | * SPDX-License-Identifier: AGPL-3.0-or-later 4 | */ 5 | 6 | // const path = require('path') 7 | const webpackConfig = require('@nextcloud/webpack-vue-config') 8 | const ESLintPlugin = require('eslint-webpack-plugin') 9 | const StyleLintPlugin = require('stylelint-webpack-plugin') 10 | 11 | const buildMode = process.env.NODE_ENV 12 | const isDev = buildMode === 'development' 13 | webpackConfig.devtool = isDev ? 'cheap-source-map' : 'source-map' 14 | // webpackConfig.bail = false 15 | 16 | webpackConfig.stats = { 17 | colors: true, 18 | modules: false, 19 | } 20 | 21 | // const appId = 'context_chat' 22 | webpackConfig.entry = { 23 | 'admin': './src/admin.js', 24 | } 25 | 26 | webpackConfig.plugins.push( 27 | new ESLintPlugin({ 28 | extensions: ['js', 'vue'], 29 | files: 'src', 30 | failOnError: !isDev, 31 | }) 32 | ) 33 | webpackConfig.plugins.push( 34 | new StyleLintPlugin({ 35 | files: 'src/**/*.{css,scss,vue}', 36 | failOnError: !isDev, 37 | }), 38 | ) 39 | 40 | module.exports = webpackConfig 41 | --------------------------------------------------------------------------------