mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-03-28 03:29:25 +01:00
Compare commits
138 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44ecf41ca6 | ||
|
|
5c92c89813 | ||
|
|
f9e3773ec3 | ||
|
|
e5a7edca03 | ||
|
|
bd015f4c56 | ||
|
|
0e60e246e1 | ||
|
|
c67653b87a | ||
|
|
643eaea84b | ||
|
|
150134a9fa | ||
|
|
6b558531be | ||
|
|
4642dee6ce | ||
|
|
78c0b1d24d | ||
|
|
0226e651c7 | ||
|
|
7ab5e65826 | ||
|
|
b7ed8b6694 | ||
|
|
4443799cc9 | ||
|
|
4219e753da | ||
|
|
f00bfff77c | ||
|
|
5e93f2661b | ||
|
|
9a8378d63a | ||
|
|
982dceb949 | ||
|
|
6a1d0e83f9 | ||
|
|
edcfd937e2 | ||
|
|
f9062616b8 | ||
|
|
efe6af9b24 | ||
|
|
8b96793c4d | ||
|
|
735b9e8ae6 | ||
|
|
c409896718 | ||
|
|
f004c002a7 | ||
|
|
d501d2dc7e | ||
|
|
8e84ece2ef | ||
|
|
a4de8d05f7 | ||
|
|
28df8e6b23 | ||
|
|
baeb96b863 | ||
|
|
d645fc161b | ||
|
|
b8cf1b6127 | ||
|
|
f5a181b09f | ||
|
|
4784cd6e43 | ||
|
|
467299b231 | ||
|
|
5dfa6d7810 | ||
|
|
571f6bb5a2 | ||
|
|
023e3f30af | ||
|
|
d6c6cb66fa | ||
|
|
b8d36da9e1 | ||
|
|
6b41ebbd45 | ||
|
|
85492454a5 | ||
|
|
77e83085d6 | ||
|
|
0ec5334e0d | ||
|
|
6cb2a0d944 | ||
|
|
6934e8b4d1 | ||
|
|
bb0c4d19d8 | ||
|
|
1c179efde2 | ||
|
|
5dc48477f6 | ||
|
|
b0b8f07661 | ||
|
|
5e290119ab | ||
|
|
ab5a7cb178 | ||
|
|
5b990b7323 | ||
|
|
92ce7400e7 | ||
|
|
d53ccd2dc8 | ||
|
|
c0b1980bbc | ||
|
|
9b74c71f29 | ||
|
|
9802dd7c70 | ||
|
|
138ad84286 | ||
|
|
34076b107b | ||
|
|
5e0fba29ca | ||
|
|
06e1c4f4f2 | ||
|
|
fbc48dd115 | ||
|
|
e4fde22dd9 | ||
|
|
826c819b4a | ||
|
|
fe0c2afe60 | ||
|
|
9220b4b83d | ||
|
|
6120e257e8 | ||
|
|
bd642ac1e8 | ||
|
|
6a737ed83f | ||
|
|
b1edef27e8 | ||
|
|
ed0b0f76ec | ||
|
|
b40d8190af | ||
|
|
8bb8b414f8 | ||
|
|
fb05ab53e2 | ||
|
|
a4e6a9bd9f | ||
|
|
5113cc3eed | ||
|
|
86575bfc73 | ||
|
|
baf16ae824 | ||
|
|
db22b0c5f6 | ||
|
|
5d97d471d0 | ||
|
|
84aa125c0f | ||
|
|
0f8a391e39 | ||
|
|
3491dda753 | ||
|
|
25f4ed37e6 | ||
|
|
62e33aeff5 | ||
|
|
e7ab2b197c | ||
|
|
63e1f56aa0 | ||
|
|
9422c76bc6 | ||
|
|
a77edcaac3 | ||
|
|
99561b420f | ||
|
|
96e5027055 | ||
|
|
460756f581 | ||
|
|
6f0fae0033 | ||
|
|
41c64fb50b | ||
|
|
d30c1a1407 | ||
|
|
9c74339893 | ||
|
|
be25408fe7 | ||
|
|
5d3c659d05 | ||
|
|
75106a8f61 | ||
|
|
b9dd32be25 | ||
|
|
58b106f388 | ||
|
|
7db8568e19 | ||
|
|
20a313ce08 | ||
|
|
650ae407f3 | ||
|
|
db69428193 | ||
|
|
bc016e6c60 | ||
|
|
45a30c0188 | ||
|
|
0e94d5daa4 | ||
|
|
744504dd1e | ||
|
|
e1c808f90d | ||
|
|
c1395794d4 | ||
|
|
a105ac1a83 | ||
|
|
bc7f84c123 | ||
|
|
dfa896e86b | ||
|
|
99b96c3df7 | ||
|
|
80ae0aacf8 | ||
|
|
d9d3d2e068 | ||
|
|
56b0d69421 | ||
|
|
782985bac0 | ||
|
|
96beab7e69 | ||
|
|
b806cefe3a | ||
|
|
e2b447e142 | ||
|
|
639b026e6f | ||
|
|
617dc111c2 | ||
|
|
d4a50f3e9c | ||
|
|
efa57ec010 | ||
|
|
6817e2e47e | ||
|
|
e12e7c1696 | ||
|
|
fbfaf5fdae | ||
|
|
00bd864831 | ||
|
|
41eb30d84d | ||
|
|
6874a2824f | ||
|
|
a3f10dd158 |
193
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
193
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
name: Bug Report
|
||||
description: Report a bug or issue with Project N.O.M.A.D.
|
||||
title: "[Bug]: "
|
||||
labels: ["bug", "needs-triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to report a bug! Please fill out the information below to help us diagnose and fix the issue.
|
||||
|
||||
**Before submitting:**
|
||||
- Search existing issues to avoid duplicates
|
||||
- Ensure you're running the latest version of N.O.M.A.D.
|
||||
- Redact any personal or sensitive information from logs/configs
|
||||
- Please don't submit issues related to running N.O.M.A.D. on Unraid or another NAS - we don't have plans to support these kinds of platforms at this time
|
||||
|
||||
- type: dropdown
|
||||
id: issue-category
|
||||
attributes:
|
||||
label: Issue Category
|
||||
description: What area is this issue related to?
|
||||
options:
|
||||
- Installation/Setup
|
||||
- AI Assistant (Ollama)
|
||||
- Knowledge Base/RAG (Document Upload)
|
||||
- Docker/Container Issues
|
||||
- GPU Configuration
|
||||
- Content Downloads (ZIM, Maps, Collections)
|
||||
- Service Management (Start/Stop/Update)
|
||||
- System Performance/Resources
|
||||
- UI/Frontend Issue
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Bug Description
|
||||
description: Provide a clear and concise description of what the bug is
|
||||
placeholder: What happened? What did you expect to happen?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: reproduction
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: How can we reproduce this issue?
|
||||
placeholder: |
|
||||
1. Go to '...'
|
||||
2. Click on '...'
|
||||
3. See error
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected-behavior
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: What did you expect to happen?
|
||||
placeholder: Describe the expected outcome
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual-behavior
|
||||
attributes:
|
||||
label: Actual Behavior
|
||||
description: What actually happened?
|
||||
placeholder: Describe what actually occurred, including any error messages
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: nomad-version
|
||||
attributes:
|
||||
label: N.O.M.A.D. Version
|
||||
description: What version of N.O.M.A.D. are you running? (Check Settings > Update or run `docker ps` and check nomad_admin image tag)
|
||||
placeholder: "e.g., 1.29.0"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: os
|
||||
attributes:
|
||||
label: Operating System
|
||||
description: What OS are you running N.O.M.A.D. on?
|
||||
options:
|
||||
- Ubuntu 24.04
|
||||
- Ubuntu 22.04
|
||||
- Ubuntu 20.04
|
||||
- Debian 13 (Trixie)
|
||||
- Debian 12 (Bookworm)
|
||||
- Debian 11 (Bullseye)
|
||||
- Other Debian-based
|
||||
- Other (not yet officially supported)
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: docker-version
|
||||
attributes:
|
||||
label: Docker Version
|
||||
description: What version of Docker are you running? (`docker --version`)
|
||||
placeholder: "e.g., Docker version 24.0.7"
|
||||
|
||||
- type: dropdown
|
||||
id: gpu-present
|
||||
attributes:
|
||||
label: Do you have a dedicated GPU?
|
||||
options:
|
||||
- "Yes"
|
||||
- "No"
|
||||
- "Not sure"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: gpu-model
|
||||
attributes:
|
||||
label: GPU Model (if applicable)
|
||||
description: What GPU model do you have? (Check Settings > System or run `nvidia-smi` if NVIDIA GPU)
|
||||
placeholder: "e.g., NVIDIA GeForce RTX 3060"
|
||||
|
||||
- type: textarea
|
||||
id: system-specs
|
||||
attributes:
|
||||
label: System Specifications
|
||||
description: Provide relevant system specs (CPU, RAM, available disk space)
|
||||
placeholder: |
|
||||
CPU:
|
||||
RAM:
|
||||
Available Disk Space:
|
||||
GPU (if any):
|
||||
|
||||
- type: textarea
|
||||
id: service-status
|
||||
attributes:
|
||||
label: Service Status (if relevant)
|
||||
description: If this is a service-related issue, what's the status of relevant services? (Check Settings > Apps or run `docker ps`)
|
||||
placeholder: |
|
||||
Paste output from `docker ps` or describe service states from the UI
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Relevant Logs
|
||||
description: |
|
||||
Include any relevant logs or error messages. **Please redact any personal/sensitive information.**
|
||||
|
||||
Useful commands for collecting logs:
|
||||
- N.O.M.A.D. management app: `docker logs nomad_admin`
|
||||
- Ollama: `docker logs nomad_ollama`
|
||||
- Qdrant: `docker logs nomad_qdrant`
|
||||
- Specific service: `docker logs nomad_<service-name>`
|
||||
placeholder: Paste relevant log output here
|
||||
render: shell
|
||||
|
||||
- type: textarea
|
||||
id: browser-console
|
||||
attributes:
|
||||
label: Browser Console Errors (if UI issue)
|
||||
description: If this is a UI issue, include any errors from your browser's developer console (F12)
|
||||
placeholder: Paste browser console errors here
|
||||
render: javascript
|
||||
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: Screenshots
|
||||
description: If applicable, add screenshots to help explain your problem (drag and drop images here)
|
||||
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Add any other context about the problem here (network setup, custom configurations, recent changes, etc.)
|
||||
|
||||
- type: checkboxes
|
||||
id: terms
|
||||
attributes:
|
||||
label: Pre-submission Checklist
|
||||
description: Please confirm the following before submitting
|
||||
options:
|
||||
- label: I have searched for existing issues that might be related to this bug
|
||||
required: true
|
||||
- label: I am running the latest version of Project N.O.M.A.D. (or have noted my version above)
|
||||
required: true
|
||||
- label: I have redacted any personal or sensitive information from logs and screenshots
|
||||
required: true
|
||||
- label: This issue is NOT related to running N.O.M.A.D. on an unsupported/non-Debian-based OS
|
||||
required: false
|
||||
17
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
17
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: 💬 Discord Community
|
||||
url: https://discord.com/invite/crosstalksolutions
|
||||
about: Join our Discord community for general questions, support, and discussions
|
||||
- name: 📖 Documentation
|
||||
url: https://projectnomad.us
|
||||
about: Check the official documentation and guides
|
||||
- name: 🏆 Community Leaderboard
|
||||
url: https://benchmark.projectnomad.us
|
||||
about: View the N.O.M.A.D. benchmark leaderboard
|
||||
- name: 🤝 Contributing Guide
|
||||
url: https://github.com/Crosstalk-Solutions/project-nomad/blob/main/CONTRIBUTING.md
|
||||
about: Learn how to contribute to Project N.O.M.A.D.
|
||||
- name: 📅 Roadmap
|
||||
url: https://roadmap.projectnomad.us
|
||||
about: See our public roadmap, vote on features, and suggest new ones
|
||||
150
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
150
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
name: Feature Request
|
||||
description: Suggest a new feature or enhancement for Project N.O.M.A.D.
|
||||
title: "[Feature]: "
|
||||
labels: ["enhancement", "needs-discussion"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for your interest in improving Project N.O.M.A.D.! Before you submit a feature request, consider checking our [roadmap](https://roadmap.projectnomad.us) to see if it's already planned or in progress. You're welcome to suggest new ideas there if you don't plan on opening PRs yourself.
|
||||
|
||||
|
||||
**Please note:** Feature requests are not guaranteed to be implemented. All requests are evaluated based on alignment with the project's goals, feasibility, and community demand.
|
||||
|
||||
**Before submitting:**
|
||||
- Search existing feature requests and our [roadmap](https://roadmap.projectnomad.us) to avoid duplicates
|
||||
- Consider if this aligns with N.O.M.A.D.'s mission: offline-first knowledge and education
|
||||
- Consider the technical feasibility of the feature: N.O.M.A.D. is designed to be containerized and run on a wide range of hardware, so features that require heavy resources (aside from GPU-intensive tasks) or complex host configurations may be less likely to be implemented
|
||||
- Consider the scope of the feature: Small, focused enhancements that can be implemented incrementally are more likely to be implemented than large, broad features that would require significant development effort or have an unclear path forward
|
||||
- If you're able to contribute code, testing, or documentation, that significantly increases the chances of your feature being implemented
|
||||
|
||||
- type: dropdown
|
||||
id: feature-category
|
||||
attributes:
|
||||
label: Feature Category
|
||||
description: What area does this feature relate to?
|
||||
options:
|
||||
- New Service/Tool Integration
|
||||
- AI Assistant Enhancement
|
||||
- Knowledge Base/RAG Improvement
|
||||
- Content Management (ZIM, Maps, Collections)
|
||||
- UI/UX Improvement
|
||||
- System Management
|
||||
- Performance Optimization
|
||||
- Documentation
|
||||
- Security
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem Statement
|
||||
description: What problem does this feature solve? Is your feature request related to a pain point?
|
||||
placeholder: I find it frustrating when... / It would be helpful if... / Users struggle with...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: Proposed Solution
|
||||
description: Describe the feature or enhancement you'd like to see
|
||||
placeholder: Add a feature that... / Change the behavior to... / Integrate with...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternative Solutions
|
||||
description: Have you considered any alternative solutions or workarounds?
|
||||
placeholder: I've tried... / Another approach could be... / A workaround is...
|
||||
|
||||
- type: textarea
|
||||
id: use-case
|
||||
attributes:
|
||||
label: Use Case
|
||||
description: Describe a specific scenario where this feature would be valuable
|
||||
placeholder: |
|
||||
As a [type of user], when I [do something], I want to [accomplish something] so that [benefit].
|
||||
|
||||
Example: Because I have a dedicated GPU, I want to be able to see in the UI if GPU support is enabled so that I can optimize performance and troubleshoot issues more easily.
|
||||
|
||||
- type: dropdown
|
||||
id: user-type
|
||||
attributes:
|
||||
label: Who would benefit from this feature?
|
||||
description: What type of users would find this most valuable?
|
||||
multiple: true
|
||||
options:
|
||||
- Individual/Home Users
|
||||
- Families
|
||||
- Teachers/Educators
|
||||
- Students
|
||||
- Survivalists/Preppers
|
||||
- Developers/Contributors
|
||||
- Organizations
|
||||
- All Users
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: priority
|
||||
attributes:
|
||||
label: How important is this feature to you?
|
||||
options:
|
||||
- Critical - Blocking my use of N.O.M.A.D.
|
||||
- High - Would significantly improve my experience
|
||||
- Medium - Would be nice to have
|
||||
- Low - Minor convenience
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: implementation-ideas
|
||||
attributes:
|
||||
label: Implementation Ideas (Optional)
|
||||
description: If you have technical suggestions for how this could be implemented, share them here
|
||||
placeholder: This could potentially use... / It might integrate with... / A possible approach is...
|
||||
|
||||
- type: textarea
|
||||
id: examples
|
||||
attributes:
|
||||
label: Examples or References
|
||||
description: Are there similar features in other applications? Include links, screenshots, or descriptions
|
||||
placeholder: Similar to how [app name] does... / See this example at [URL]
|
||||
|
||||
- type: dropdown
|
||||
id: willing-to-contribute
|
||||
attributes:
|
||||
label: Would you be willing to help implement this?
|
||||
description: Contributing increases the likelihood of implementation
|
||||
options:
|
||||
- "Yes - I can write the code"
|
||||
- "Yes - I can help test"
|
||||
- "Yes - I can help with documentation"
|
||||
- "Maybe - with guidance"
|
||||
- "No - I don't have the skills/time"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Add any other context, mockups, diagrams, or information about the feature request
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Pre-submission Checklist
|
||||
description: Please confirm the following before submitting
|
||||
options:
|
||||
- label: I have searched for existing feature requests that might be similar
|
||||
required: true
|
||||
- label: This feature aligns with N.O.M.A.D.'s mission of offline-first knowledge and education
|
||||
required: true
|
||||
- label: I understand that feature requests are not guaranteed to be implemented
|
||||
required: true
|
||||
7
.github/dependabot.yaml
vendored
Normal file
7
.github/dependabot.yaml
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/admin"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
target-branch: "rc"
|
||||
25
.github/workflows/build-admin-on-pr.yml
vendored
Normal file
25
.github/workflows/build-admin-on-pr.yml
vendored
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
name: Build Admin
|
||||
|
||||
on: pull_request
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
working-directory: ./admin
|
||||
|
||||
- name: Run build
|
||||
run: npm run build
|
||||
working-directory: ./admin
|
||||
51
.github/workflows/build-disk-collector.yml
vendored
Normal file
51
.github/workflows/build-disk-collector.yml
vendored
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
name: Build Disk Collector Image
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Semantic version to label the Docker image under (no "v" prefix, e.g. "1.2.3")'
|
||||
required: true
|
||||
type: string
|
||||
tag_latest:
|
||||
description: 'Also tag this image as :latest?'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
check_authorization:
|
||||
name: Check authorization to publish new Docker image
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
isAuthorized: ${{ steps.check-auth.outputs.is_authorized }}
|
||||
steps:
|
||||
- name: check-auth
|
||||
id: check-auth
|
||||
run: echo "is_authorized=${{ contains(secrets.DEPLOYMENT_AUTHORIZED_USERS, github.triggering_actor) }}" >> $GITHUB_OUTPUT
|
||||
build:
|
||||
name: Build disk-collector image
|
||||
needs: check_authorization
|
||||
if: needs.check_authorization.outputs.isAuthorized == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: install/sidecar-disk-collector
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/crosstalk-solutions/project-nomad-disk-collector:${{ inputs.version }}
|
||||
ghcr.io/crosstalk-solutions/project-nomad-disk-collector:v${{ inputs.version }}
|
||||
${{ inputs.tag_latest && 'ghcr.io/crosstalk-solutions/project-nomad-disk-collector:latest' || '' }}
|
||||
|
|
@ -1,12 +1,17 @@
|
|||
name: Build Docker Image
|
||||
name: Build Primary Docker Image
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Semantic version to label the Docker image under'
|
||||
description: 'Semantic version to label the Docker image under (no "v" prefix, e.g. "1.2.3")'
|
||||
required: true
|
||||
type: string
|
||||
tag_latest:
|
||||
description: 'Also tag this image as :latest? (Keep false for RC and beta releases)'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
check_authorization:
|
||||
|
|
@ -28,7 +33,7 @@ jobs:
|
|||
packages: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
|
|
@ -41,4 +46,9 @@ jobs:
|
|||
push: true
|
||||
tags: |
|
||||
ghcr.io/crosstalk-solutions/project-nomad:${{ inputs.version }}
|
||||
ghcr.io/crosstalk-solutions/project-nomad:latest
|
||||
ghcr.io/crosstalk-solutions/project-nomad:v${{ inputs.version }}
|
||||
${{ inputs.tag_latest && 'ghcr.io/crosstalk-solutions/project-nomad:latest' || '' }}
|
||||
build-args: |
|
||||
VERSION=${{ inputs.version }}
|
||||
BUILD_DATE=${{ github.event.workflow_run.created_at }}
|
||||
VCS_REF=${{ github.sha }}
|
||||
51
.github/workflows/build-sidecar-updater.yml
vendored
Normal file
51
.github/workflows/build-sidecar-updater.yml
vendored
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
name: Build Sidecar Updater Image
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Semantic version to label the Docker image under (no "v" prefix, e.g. "1.2.3")'
|
||||
required: true
|
||||
type: string
|
||||
tag_latest:
|
||||
description: 'Also tag this image as :latest?'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
check_authorization:
|
||||
name: Check authorization to publish new Docker image
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
isAuthorized: ${{ steps.check-auth.outputs.is_authorized }}
|
||||
steps:
|
||||
- name: check-auth
|
||||
id: check-auth
|
||||
run: echo "is_authorized=${{ contains(secrets.DEPLOYMENT_AUTHORIZED_USERS, github.triggering_actor) }}" >> $GITHUB_OUTPUT
|
||||
build:
|
||||
name: Build sidecar-updater image
|
||||
needs: check_authorization
|
||||
if: needs.check_authorization.outputs.isAuthorized == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: install/sidecar-updater
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/crosstalk-solutions/project-nomad-sidecar-updater:${{ inputs.version }}
|
||||
ghcr.io/crosstalk-solutions/project-nomad-sidecar-updater:v${{ inputs.version }}
|
||||
${{ inputs.tag_latest && 'ghcr.io/crosstalk-solutions/project-nomad-sidecar-updater:latest' || '' }}
|
||||
21
.github/workflows/release.yml
vendored
21
.github/workflows/release.yml
vendored
|
|
@ -22,7 +22,7 @@ jobs:
|
|||
newVersion: ${{ steps.semver.outputs.new_release_version }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
|
@ -37,12 +37,15 @@ jobs:
|
|||
GIT_COMMITTER_EMAIL: dev@cosmistack.com
|
||||
|
||||
- name: Finalize release notes
|
||||
if: steps.semver.outputs.new_release_published == 'true'
|
||||
# Skip for pre-releases (versions containing a hyphen, e.g. 1.27.0-rc.1)
|
||||
if: |
|
||||
steps.semver.outputs.new_release_published == 'true' &&
|
||||
!contains(steps.semver.outputs.new_release_version, '-')
|
||||
id: finalize-notes
|
||||
env:
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
run: |
|
||||
git pull origin master
|
||||
git pull origin main
|
||||
chmod +x .github/scripts/finalize-release-notes.sh
|
||||
EXIT_CODE=0
|
||||
.github/scripts/finalize-release-notes.sh \
|
||||
|
|
@ -55,17 +58,23 @@ jobs:
|
|||
fi
|
||||
|
||||
- name: Commit finalized release notes
|
||||
if: steps.semver.outputs.new_release_published == 'true' && steps.finalize-notes.outputs.has_notes == 'true'
|
||||
if: |
|
||||
steps.semver.outputs.new_release_published == 'true' &&
|
||||
steps.finalize-notes.outputs.has_notes == 'true' &&
|
||||
!contains(steps.semver.outputs.new_release_version, '-')
|
||||
run: |
|
||||
git config user.name "cosmistack-bot"
|
||||
git config user.email "dev@cosmistack.com"
|
||||
git remote set-url origin https://x-access-token:${{ secrets.COSMISTACKBOT_ACCESS_TOKEN }}@github.com/${{ github.repository }}.git
|
||||
git add admin/docs/release-notes.md
|
||||
git commit -m "docs(release): finalize v${{ steps.semver.outputs.new_release_version }} release notes [skip ci]"
|
||||
git push origin master
|
||||
git push origin main
|
||||
|
||||
- name: Update GitHub release body
|
||||
if: steps.semver.outputs.new_release_published == 'true' && steps.finalize-notes.outputs.has_notes == 'true'
|
||||
if: |
|
||||
steps.semver.outputs.new_release_published == 'true' &&
|
||||
steps.finalize-notes.outputs.has_notes == 'true' &&
|
||||
!contains(steps.semver.outputs.new_release_version, '-')
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.COSMISTACKBOT_ACCESS_TOKEN }}
|
||||
run: |
|
||||
|
|
|
|||
58
.github/workflows/validate-collection-urls.yml
vendored
Normal file
58
.github/workflows/validate-collection-urls.yml
vendored
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
name: Validate Collection URLs
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'collections/**.json'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'collections/**.json'
|
||||
|
||||
jobs:
|
||||
validate-urls:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Extract and validate URLs
|
||||
run: |
|
||||
FAILED=0
|
||||
CHECKED=0
|
||||
FAILED_URLS=""
|
||||
|
||||
# Recursively extract all non-null string URLs from every JSON file in collections/
|
||||
URLS=$(jq -r '.. | .url? | select(type == "string")' collections/*.json | sort -u)
|
||||
|
||||
while IFS= read -r url; do
|
||||
[ -z "$url" ] && continue
|
||||
CHECKED=$((CHECKED + 1))
|
||||
printf "Checking: %s ... " "$url"
|
||||
|
||||
# Use Range: bytes=0-0 to avoid downloading the full file.
|
||||
# --max-filesize 1 aborts early if the server ignores the Range header
|
||||
# and returns 200 with the full body. The HTTP status is still captured.
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
--range 0-0 \
|
||||
--max-filesize 1 \
|
||||
--max-time 30 \
|
||||
--location \
|
||||
"$url")
|
||||
|
||||
if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "206" ]; then
|
||||
echo "OK ($HTTP_CODE)"
|
||||
else
|
||||
echo "FAILED ($HTTP_CODE)"
|
||||
FAILED=$((FAILED + 1))
|
||||
FAILED_URLS="$FAILED_URLS\n - $url (HTTP $HTTP_CODE)"
|
||||
fi
|
||||
done <<< "$URLS"
|
||||
|
||||
echo ""
|
||||
echo "Checked $CHECKED URLs, $FAILED failed."
|
||||
|
||||
if [ "$FAILED" -gt 0 ]; then
|
||||
echo ""
|
||||
echo "Broken URLs:"
|
||||
printf "%b\n" "$FAILED_URLS"
|
||||
exit 1
|
||||
fi
|
||||
|
|
@ -1,5 +1,8 @@
|
|||
{
|
||||
"branches": ["master"],
|
||||
"branches": [
|
||||
"main",
|
||||
{ "name": "rc", "prerelease": "rc" }
|
||||
],
|
||||
"plugins": [
|
||||
"@semantic-release/commit-analyzer",
|
||||
"@semantic-release/release-notes-generator",
|
||||
|
|
|
|||
128
CODE_OF_CONDUCT.md
Normal file
128
CODE_OF_CONDUCT.md
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
https://www.contributor-covenant.org/faq. Translations are available at
|
||||
https://www.contributor-covenant.org/translations.
|
||||
187
CONTRIBUTING.md
Normal file
187
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
# Contributing to Project N.O.M.A.D.
|
||||
|
||||
Thank you for your interest in contributing to Project N.O.M.A.D.! Community contributions are what keep this project growing and improving. Please read this guide fully before getting started — it will save you (and the maintainers) a lot of time.
|
||||
|
||||
> **Note:** Acceptance of contributions is not guaranteed. All pull requests are evaluated based on quality, relevance, and alignment with the project's goals. The maintainers of Project N.O.M.A.D. ("Nomad") reserve the right accept, deny, or modify any pull request at their sole discretion.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Code of Conduct](#code-of-conduct)
|
||||
- [Before You Start](#before-you-start)
|
||||
- [Getting Started](#getting-started)
|
||||
- [Development Workflow](#development-workflow)
|
||||
- [Commit Messages](#commit-messages)
|
||||
- [Release Notes](#release-notes)
|
||||
- [Versioning](#versioning)
|
||||
- [Submitting a Pull Request](#submitting-a-pull-request)
|
||||
- [Feedback & Community](#feedback--community)
|
||||
|
||||
---
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
Please read and review our full [Code of Conduct](https://github.com/Crosstalk-Solutions/project-nomad/blob/main/CODE_OF_CONDUCT.md) before contributing. In short: please be respectful and considerate in all interactions with maintainers and other contributors.
|
||||
|
||||
We are committed to providing a welcoming environment for everyone. Disrespectful or abusive behavior will not be tolerated.
|
||||
|
||||
---
|
||||
|
||||
## Before You Start
|
||||
|
||||
**Open an issue first.** Before writing any code, please [open an issue](../../issues/new) to discuss your proposed change. This helps avoid duplicate work and ensures your contribution aligns with the project's direction.
|
||||
|
||||
When opening an issue:
|
||||
- Use a clear, descriptive title
|
||||
- Describe the problem you're solving or the feature you want to add
|
||||
- If it's a bug, include steps to reproduce it and as much detail about your environment as possible
|
||||
- Ensure you redact any personal or sensitive information in any logs, configs, etc.
|
||||
|
||||
---
|
||||
|
||||
## Getting Started with Contributing
|
||||
**Please note**: this is the Getting Started guide for developing and contributing to Nomad, NOT [installing Nomad](https://github.com/Crosstalk-Solutions/project-nomad/blob/main/README.md) for regular use!
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- A Debian-based OS (Ubuntu recommended)
|
||||
- `sudo`/root privileges
|
||||
- Docker installed and running
|
||||
- A stable internet connection (required for dependency downloads)
|
||||
- Node.js (for frontend/admin work)
|
||||
|
||||
### Fork & Clone
|
||||
|
||||
1. Click **Fork** at the top right of this repository
|
||||
2. Clone your fork locally:
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/project-nomad.git
|
||||
cd project-nomad
|
||||
```
|
||||
3. Add the upstream remote so you can stay in sync:
|
||||
```bash
|
||||
git remote add upstream https://github.com/Crosstalk-Solutions/project-nomad.git
|
||||
```
|
||||
|
||||
### Avoid Installing a Release Version Locally
|
||||
Because Nomad relies heavily on Docker, we actually recommend against installing a release version of the project on the same local machine where you are developing. This can lead to conflicts with ports, volumes, and other resources. Instead, you can run your development version in a separate Docker environment while keeping your local machine clean. It certainly __can__ be done, but it adds complexity to your setup and workflow. If you choose to install a release version locally, please ensure you have a clear strategy for managing potential conflicts and resource usage.
|
||||
|
||||
---
|
||||
|
||||
## Development Workflow
|
||||
|
||||
1. **Sync with upstream** before starting any new work. We prefer rebasing over merge commits to keep a clean, linear git history as much as possible (this also makes it easier for maintainers to review and merge your changes). To sync with upstream:
|
||||
```bash
|
||||
git fetch upstream
|
||||
git checkout main
|
||||
git rebase upstream/main
|
||||
```
|
||||
|
||||
2. **Create a feature branch** off `main` with a descriptive name:
|
||||
```bash
|
||||
git checkout -b fix/issue-123
|
||||
# or
|
||||
git checkout -b feature/add-new-tool
|
||||
```
|
||||
|
||||
3. **Make your changes.** Follow existing code style and conventions. Test your changes locally against a running N.O.M.A.D. instance before submitting.
|
||||
|
||||
4. **Add release notes** (see [Release Notes](#release-notes) below).
|
||||
|
||||
5. **Commit your changes** using [Conventional Commits](#commit-messages).
|
||||
|
||||
6. **Push your branch** and open a pull request.
|
||||
|
||||
---
|
||||
|
||||
## Commit Messages
|
||||
|
||||
This project uses [Conventional Commits](https://www.conventionalcommits.org/). All commit messages must follow this format:
|
||||
|
||||
```
|
||||
<type>(<scope>): <description>
|
||||
```
|
||||
|
||||
**Common types:**
|
||||
|
||||
| Type | When to use |
|
||||
|------|-------------|
|
||||
| `feat` | A new user-facing feature |
|
||||
| `fix` | A bug fix |
|
||||
| `docs` | Documentation changes only |
|
||||
| `refactor` | Code change that isn't a fix or feature and does not affect functionality |
|
||||
| `chore` | Build process, dependency updates, tooling |
|
||||
| `test` | Adding or updating tests |
|
||||
|
||||
**Scope** is optional but encouraged — use it to indicate the area of the codebase affected (e.g., `api`, `ui`, `maps`).
|
||||
|
||||
**Examples:**
|
||||
```
|
||||
feat(ui): add dark mode toggle to Command Center
|
||||
fix(api): resolve container status not updating after restart
|
||||
docs: update hardware requirements in README
|
||||
chore(deps): bump docker-compose to v2.24
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Release Notes
|
||||
|
||||
Human-readable release notes live in [`admin/docs/release-notes.md`](admin/docs/release-notes.md) and are displayed directly in the Command Center UI.
|
||||
|
||||
When your changes include anything user-facing, **add a summary to the `## Unreleased` section** at the top of that file under the appropriate heading:
|
||||
|
||||
- **Features** — new user-facing capabilities
|
||||
- **Bug Fixes** — corrections to existing behavior
|
||||
- **Improvements** — enhancements, refactors, docs, or dependency updates
|
||||
|
||||
Use the format `- **Area**: Description` to stay consistent with existing entries.
|
||||
|
||||
**Example:**
|
||||
```markdown
|
||||
## Unreleased
|
||||
|
||||
### Features
|
||||
- **Maps**: Added support for downloading South America regional maps
|
||||
|
||||
### Bug Fixes
|
||||
- **AI Chat**: Fixed document upload failing on filenames with special characters
|
||||
```
|
||||
|
||||
> When a release is triggered, CI automatically stamps the version and date, commits the update, and publishes the content to the GitHub release. You do not need to do this manually.
|
||||
|
||||
---
|
||||
|
||||
## Versioning
|
||||
|
||||
This project uses [Semantic Versioning](https://semver.org/). Versions are managed in the root `package.json` and updated automatically by `semantic-release`. The `project-nomad` Docker image uses this version. The `admin/package.json` version stays at `0.0.0` and should not be changed manually.
|
||||
|
||||
---
|
||||
|
||||
## Submitting a Pull Request
|
||||
|
||||
1. Push your branch to your fork:
|
||||
```bash
|
||||
git push origin your-branch-name
|
||||
```
|
||||
2. Open a pull request against the `main` branch of this repository
|
||||
3. In the PR description:
|
||||
- Summarize what your changes do and why
|
||||
- Reference the related issue (e.g., `Closes #123`)
|
||||
- Note any relevant testing steps or environment details
|
||||
4. Be responsive to feedback — maintainers may request changes. Pull requests with no activity for an extended period may be closed.
|
||||
|
||||
---
|
||||
|
||||
## Feedback & Community
|
||||
|
||||
Have questions or want to discuss ideas before opening an issue? Join the community:
|
||||
|
||||
- **Discord:** [Join the Crosstalk Solutions server](https://discord.com/invite/crosstalksolutions) — the best place to get help, share your builds, and talk with other N.O.M.A.D. users
|
||||
- **Website:** [www.projectnomad.us](https://www.projectnomad.us)
|
||||
- **Benchmark Leaderboard:** [benchmark.projectnomad.us](https://benchmark.projectnomad.us)
|
||||
|
||||
---
|
||||
|
||||
*Project N.O.M.A.D. is licensed under the [Apache License 2.0](LICENSE).*
|
||||
24
Dockerfile
24
Dockerfile
|
|
@ -24,13 +24,35 @@ RUN node ace build
|
|||
|
||||
# Production stage
|
||||
FROM base
|
||||
ARG VERSION=dev
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
# Labels
|
||||
LABEL org.opencontainers.image.title="Project N.O.M.A.D" \
|
||||
org.opencontainers.image.description="The Project N.O.M.A.D Official Docker image" \
|
||||
org.opencontainers.image.version="${VERSION}" \
|
||||
org.opencontainers.image.created="${BUILD_DATE}" \
|
||||
org.opencontainers.image.revision="${VCS_REF}" \
|
||||
org.opencontainers.image.vendor="Crosstalk Solutions, LLC" \
|
||||
org.opencontainers.image.documentation="https://github.com/CrosstalkSolutions/project-nomad/blob/main/README.md" \
|
||||
org.opencontainers.image.source="https://github.com/CrosstalkSolutions/project-nomad" \
|
||||
org.opencontainers.image.licenses="Apache-2.0"
|
||||
|
||||
ENV NODE_ENV=production
|
||||
WORKDIR /app
|
||||
COPY --from=production-deps /app/node_modules /app/node_modules
|
||||
COPY --from=build /app/build /app
|
||||
# Copy root package.json for version info
|
||||
COPY package.json /app/version.json
|
||||
|
||||
# Copy docs and README for access within the container
|
||||
COPY admin/docs /app/docs
|
||||
COPY README.md /app/README.md
|
||||
|
||||
# Copy entrypoint script and ensure it's executable
|
||||
COPY install/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||
|
||||
EXPOSE 8080
|
||||
CMD ["node", "./bin/server.js"]
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||
100
FAQ.md
Normal file
100
FAQ.md
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
# Frequently Asked Questions (FAQ)
|
||||
|
||||
Find answers to some of the most common questions about Project N.O.M.A.D.
|
||||
|
||||
## Can I customize the port(s) that NOMAD uses?
|
||||
|
||||
Yes, you can customize the ports that NOMAD's core services (Command Center, MySQL, Redis) use. Please refer to the [Advanced Installation](README.md#advanced-installation) section of the README for more details on how to do this.
|
||||
|
||||
Note: As of 3/24/2026, only the core services defined in the `docker-compose.yml` file currently support port customization - the installable applications (e.g. Ollama, Kiwix, etc.) do not yet support this, but we have multiple PR's in the works to add this feature for all installable applications in a future release.
|
||||
|
||||
## Can I customize the storage location for NOMAD's data?
|
||||
|
||||
Yes, you can customize the storage location for NOMAD's content by modifying the `docker-compose.yml` file to adjust the appropriate bind mounts to point to your desired storage location on your host machine. Please refer to the [Advanced Installation](README.md#advanced-installation) section of the README for more details on how to do this.
|
||||
|
||||
## Can I run NOMAD on MAC, WSL2, or a non-Debian-based Distro?
|
||||
|
||||
See [Why does NOMAD require a Debian-based OS?](#why-does-nomad-require-a-debian-based-os)
|
||||
|
||||
## Why does NOMAD require a Debian-based OS?
|
||||
|
||||
Project N.O.M.A.D. is currently designed to run on Debian-based Linux distributions (with Ubuntu being the recommended distro) because our installation scripts and Docker configurations are optimized for this environment. While it's technically possible to run the Docker containers on other operating systems that support Docker, we have not tested or optimized the installation process for non-Debian-based systems, so we cannot guarantee a smooth experience on those platforms at this time.
|
||||
|
||||
Support for other operating systems will come in the future, but because our development resources are limited as a free and open-source project, we needed to prioritize our efforts and focus on a narrower set of supported platforms for the initial release. We chose Debian-based Linux as our starting point because it's widely used, easy to spin up, and provides a stable environment for running Docker containers.
|
||||
|
||||
Community members have provided guides for running N.O.M.A.D. on other platforms (e.g. WSL2, Mac, etc.) in our Discord community and [Github Discussions](https://github.com/Crosstalk-Solutions/project-nomad/discussions), so if you're interested in running N.O.M.A.D. on a non-Debian-based system, we recommend checking there for any available resources or guides. However, keep in mind that if you choose to run N.O.M.A.D. on a non-Debian-based system, you may encounter issues that we won't be able to provide support for, and you may need to have a higher level of technical expertise to troubleshoot and resolve any problems that arise.
|
||||
|
||||
## Can I run NOMAD on a Raspberry Pi or other ARM-based device?
|
||||
Project N.O.M.A.D. is currently designed to run on x86-64 architecture, and we have not yet tested or optimized it for ARM-based devices like the Raspberry Pi (and have not published any official images for ARM architecture).
|
||||
|
||||
Support for ARM-based devices is on our roadmap, but our initial focus was on x86-64 hardware due to its widespread use and compatibility with a wide range of applications.
|
||||
|
||||
Community members have forked and published their own ARM-compatible images and installation guides for running N.O.M.A.D. on Raspberry Pi and other ARM-based devices in our Discord community and [Github Discussions](https://github.com/Crosstalk-Solutions/project-nomad/discussions), but these are not officially supported by the core development team, and we cannot guarantee their functionality or provide support for any issues that arise when using these community-created resources.
|
||||
|
||||
## What are the hardware requirements for running NOMAD?
|
||||
|
||||
Project N.O.M.A.D. itself is quite lightweight and can run on even modest x86-64 hardware, but the tools and resources you choose to install with N.O.M.A.D. will determine the specs required for your unique deployment. Please see the [Hardware Guide](https://www.projectnomad.us/hardware) for detailed build recommendations at various price points.
|
||||
|
||||
## Does NOMAD support languages other than English?
|
||||
|
||||
As of March 2026, Project N.O.M.A.D.'s UI is only available in English, and the majority of the tools and resources available through N.O.M.A.D. are also primarily in English. However, we have multi-language support on our roadmap for a future release, and we are actively working on adding support for additional languages both in the UI and in the available tools/resources. If you're interested in contributing to this effort, please check out our [CONTRIBUTING.md](CONTRIBUTING.md) file for guidelines on how to get involved.
|
||||
|
||||
## What technologies is NOMAD built with?
|
||||
|
||||
Project N.O.M.A.D. is built using a combination of technologies, including:
|
||||
- **Docker:** for containerization of the Command Center and its dependencies
|
||||
- **Node.js & TypeScript:** for the backend of the Command Center, particularly the [AdonisJS](https://adonisjs.com/) framework
|
||||
- **React:** for the frontend of the Command Center, utilizing [Vite](https://vitejs.dev/) and [Inertia.js](https://inertiajs.com/) under the hood
|
||||
- **MySQL:** for the Command Center's database
|
||||
- **Redis:** for various caching, background jobs, "cron" tasks, and other internal processes within the Command Center
|
||||
|
||||
NOMAD makes use of the Docker-outside-of-Docker ("DooD") pattern, which allows the Command Center to manage and orchestrate other Docker containers on the host machine without needing to run Docker itself inside a container. This approach provides better performance and compatibility with a wider range of host environments while still allowing for powerful container management capabilities through the Command Center's UI.
|
||||
|
||||
## Can I run NOMAD if I have existing Docker containers on my machine?
|
||||
Yes, you can safely run Project N.O.M.A.D. on a machine that already has existing Docker containers. NOMAD is designed to coexist with other Docker containers and will not interfere with them as long as there are no port conflicts or resource constraints.
|
||||
|
||||
All of NOMAD's containers are prefixed with `nomad_` in their names, so they can be easily identified and managed separately from any other containers you may have running. Just make sure to review the ports that NOMAD's core services (Command Center, MySQL, Redis) use during installation and adjust them if necessary to avoid conflicts with your existing containers.
|
||||
|
||||
## Why does NOMAD require access to the Docker socket?
|
||||
|
||||
See [What technologies is NOMAD built with?](#what-technologies-is-nomad-built-with)
|
||||
|
||||
## Can I use any AI models?
|
||||
NOMAD by default uses Ollama inside of a docker container to run LLM Models for the AI Assistant. So if you find a model on HuggingFace for example, you won't be able to use that model in NOMAD. The list of available models in the AI Assistant settings (/settings/models) may not show all of the models you are looking for. If you found a model from https://ollama.com/search that you'd like to try and its not in the settings page, you can use a curl command to download the model.
|
||||
`curl -X POST -H "Content-Type: application/json" -d '{"model":"MODEL_NAME_HERE"}' http://localhost:8080/api/ollama/models` replacing MODEL_NAME_HERE with the model name from whats in the ollama website.
|
||||
|
||||
## Do I have to install the AI features in NOMAD?
|
||||
|
||||
No, the AI features in NOMAD (Ollama, Qdrant, custom RAG pipeline, etc.) are all optional and not required to use the core functionality of NOMAD.
|
||||
|
||||
## Is NOMAD actually free? Are there any hidden costs?
|
||||
Yes, Project N.O.M.A.D. is completely free and open-source software licensed under the Apache License 2.0. There are no hidden costs or fees associated with using NOMAD itself, and we don't have any plans to introduce "premium" features or paid tiers.
|
||||
|
||||
Aside from the cost of the hardware you choose to run it on, there are no costs associated with using NOMAD.
|
||||
|
||||
## Do you sell hardware or pre-built devices with NOMAD pre-installed?
|
||||
|
||||
No, we do not sell hardware or pre-built devices with NOMAD pre-installed at this time. Project N.O.M.A.D. is a free and open-source software project, and we provide detailed installation instructions and hardware recommendations for users to set up their own NOMAD instances on compatible hardware of their choice. The tradeoff to this DIY approach is some additional setup time and technical know-how required on the user's end, but it also allows for greater flexibility and customization in terms of hardware selection and configuration to best suit each user's unique needs, budget, and preferences.
|
||||
|
||||
## How quickly are issues resolved when reported?
|
||||
|
||||
We strive to address and resolve issues as quickly as possible, but please keep in mind that Project N.O.M.A.D. is a free and open-source project maintained by a small team of volunteers. We prioritize issues based on their severity, impact on users, and the resources required to resolve them. Critical issues that affect a large number of users are typically addressed more quickly, while less severe issues may take longer to resolve. Aside from the development efforts needed to address the issue, we do our best to conduct thorough testing and validation to ensure that any fix we implement doesn't introduce new issues or regressions, which also adds to the time it takes to resolve an issue.
|
||||
|
||||
We also encourage community involvement in troubleshooting and resolving issues, so if you encounter a problem, please consider checking our Discord community and Github Discussions for potential solutions or workarounds while we work on an official fix.
|
||||
|
||||
## How often are new features added or updates released?
|
||||
|
||||
We aim to release updates and new features on a regular basis, but the exact timing can vary based on the complexity of the features being developed, the resources available to our volunteer development team, and the feedback and needs of our community. We typically release smaller "patch" versions more frequently to address bugs and make minor improvements, while larger feature releases may take more time to develop and test before they're ready for release.
|
||||
|
||||
## I opened a PR to contribute a new feature or fix a bug. How long does it usually take for PRs to be reviewed and merged?
|
||||
We appreciate all contributions to the project and strive to review and merge pull requests (PRs) as quickly as possible. The time it takes for a PR to be reviewed and merged can vary based on several factors, including the complexity of the changes, the current workload of our maintainers, and the need for any additional testing or revisions.
|
||||
|
||||
Because NOMAD is still a young project, some PRs (particularly those for new features) may take longer to review and merge as we prioritize building out the core functionality and ensuring stability before adding new features. However, we do our best to provide timely feedback on all PRs and keep contributors informed about the status of their contributions.
|
||||
|
||||
## I have a question that isn't answered here. Where can I ask for help?
|
||||
|
||||
If you have a question that isn't answered in this FAQ, please feel free to ask for help in our Discord community (https://discord.com/invite/crosstalksolutions) or on our Github Discussions page (https://github.com/Crosstalk-Solutions/project-nomad/discussions).
|
||||
|
||||
## I have a suggestion for a new feature or improvement. How can I share it?
|
||||
|
||||
We welcome and encourage suggestions for new features and improvements! We highly encourage sharing your ideas (or upvoting existing suggestions) on our public roadmap at https://roadmap.projectnomad.us, where we track new feature requests. This is the best way to ensure that your suggestion is seen by the development team and the community, and it also allows other community members to upvote and show support for your idea, which can help prioritize it for future development.
|
||||
190
LICENSE
Normal file
190
LICENSE
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to the Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by the Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding any notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
Copyright 2024-2026 Crosstalk Solutions LLC
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
65
README.md
65
README.md
|
|
@ -1,7 +1,5 @@
|
|||
# *NOTE: Project N.O.M.A.D. is still in active development and should not be considered stable!*
|
||||
|
||||
<div align="center">
|
||||
<img src="https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/master/admin/public/project_nomad_logo.png" width="200" height="200"/>
|
||||
<img src="https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/admin/public/project_nomad_logo.png" width="200" height="200"/>
|
||||
|
||||
# Project N.O.M.A.D.
|
||||
### Node for Offline Media, Archives, and Data
|
||||
|
|
@ -23,13 +21,18 @@ Project N.O.M.A.D. can be installed on any Debian-based operating system (we rec
|
|||
|
||||
*Note: sudo/root privileges are required to run the install script*
|
||||
|
||||
#### Quick Install
|
||||
### Quick Install (Debian-based OS Only)
|
||||
```bash
|
||||
sudo apt-get update && sudo apt-get install -y curl && curl -fsSL https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/master/install/install_nomad.sh -o install_nomad.sh && sudo bash install_nomad.sh
|
||||
sudo apt-get update && sudo apt-get install -y curl && curl -fsSL https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/install/install_nomad.sh -o install_nomad.sh && sudo bash install_nomad.sh
|
||||
```
|
||||
|
||||
Project N.O.M.A.D. is now installed on your device! Open a browser and navigate to `http://localhost:8080` (or `http://DEVICE_IP:8080`) to start exploring!
|
||||
|
||||
For a complete step-by-step walkthrough (including Ubuntu installation), see the [Installation Guide](https://www.projectnomad.us/install).
|
||||
|
||||
### Advanced Installation
|
||||
For more control over the installation process, copy and paste the [Docker Compose template](https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/install/management_compose.yaml) into a `docker-compose.yml` file and customize it to your liking (be sure to replace any placeholders with your actual values). Then, run `docker compose up -d` to start the Command Center and its dependencies. Note: this method is recommended for advanced users only, as it requires familiarity with Docker and manual configuration before starting.
|
||||
|
||||
## How It Works
|
||||
N.O.M.A.D. is a management UI ("Command Center") and API that orchestrates a collection of containerized tools and resources via [Docker](https://www.docker.com/). It handles installation, configuration, and updates for everything — so you don't have to.
|
||||
|
||||
|
|
@ -82,8 +85,13 @@ To run LLM's and other included AI tools:
|
|||
- OS: Debian-based (Ubuntu recommended)
|
||||
- Stable internet connection (required during install only)
|
||||
|
||||
**For detailed build recommendations at three price points ($150–$1,000+), see the [Hardware Guide](https://www.projectnomad.us/hardware).**
|
||||
|
||||
Again, Project N.O.M.A.D. itself is quite lightweight - it's the tools and resources you choose to install with N.O.M.A.D. that will determine the specs required for your unique deployment
|
||||
|
||||
## Frequently Asked Questions (FAQ)
|
||||
For answers to common questions about Project N.O.M.A.D., please see our [FAQ](FAQ.md) page.
|
||||
|
||||
## About Internet Usage & Privacy
|
||||
Project N.O.M.A.D. is designed for offline usage. An internet connection is only required during the initial installation (to download dependencies) and if you (the user) decide to download additional tools and resources at a later time. Otherwise, N.O.M.A.D. does not require an internet connection and has ZERO built-in telemetry.
|
||||
|
||||
|
|
@ -92,49 +100,24 @@ To test internet connectivity, N.O.M.A.D. attempts to make a request to Cloudfla
|
|||
## About Security
|
||||
By design, Project N.O.M.A.D. is intended to be open and available without hurdles - it includes no authentication. If you decide to connect your device to a local network after install (e.g. for allowing other devices to access it's resources), you can block/open ports to control which services are exposed.
|
||||
|
||||
**Will authentication be added in the future?** Maybe. It's not currently a priority, but if there's enough demand for it, we may consider building in an optional authentication layer in a future release to support uses cases where multiple users need access to the same instance but with different permission levels (e.g. family use with parental controls, classroom use with teacher/admin accounts, etc.). For now, we recommend using network-level controls to manage access if you're planning to expose your N.O.M.A.D. instance to other devices on a local network. N.O.M.A.D. is not designed to be exposed directly to the internet, and we strongly advise against doing so unless you really know what you're doing, have taken appropriate security measures, and understand the risks involved.
|
||||
**Will authentication be added in the future?** Maybe. It's not currently a priority, but if there's enough demand for it, we may consider building in an optional authentication layer in a future release to support uses cases where multiple users need access to the same instance but with different permission levels (e.g. family use with parental controls, classroom use with teacher/admin accounts, etc.). We have a suggestion for this on our public roadmap, so if this is something you'd like to see, please upvote it here: https://roadmap.projectnomad.us/posts/1/user-authentication-please-build-in-user-auth-with-admin-user-roles
|
||||
|
||||
For now, we recommend using network-level controls to manage access if you're planning to expose your N.O.M.A.D. instance to other devices on a local network. N.O.M.A.D. is not designed to be exposed directly to the internet, and we strongly advise against doing so unless you really know what you're doing, have taken appropriate security measures, and understand the risks involved.
|
||||
|
||||
## Contributing
|
||||
Contributions are welcome and appreciated! Please read this section fully to understand how to contribute to the project.
|
||||
|
||||
### General Guidelines
|
||||
|
||||
- **Open an issue first**: Before starting work on a new feature or bug fix, please open an issue to discuss your proposed changes. This helps ensure that your contribution aligns with the project's goals and avoids duplicate work. Title the issue clearly and provide a detailed description of the problem or feature you want to work on.
|
||||
- **Fork the repository**: Click the "Fork" button at the top right of the repository page to create a copy of the project under your GitHub account.
|
||||
- **Create a new branch**: In your forked repository, create a new branch for your work. Use a descriptive name for the branch that reflects the purpose of your changes (e.g., `fix/issue-123` or `feature/add-new-tool`).
|
||||
- **Make your changes**: Implement your changes in the new branch. Follow the existing code style and conventions used in the project. Be sure to test your changes locally to ensure they work as expected.
|
||||
- **Add Release Notes**: If your changes include new features, bug fixes, or improvements, please see the "Release Notes" section below to properly document your contribution for the next release.
|
||||
- **Conventional Commits**: When committing your changes, please use conventional commit messages to provide clear and consistent commit history. The format is `<type>(<scope>): <description>`, where:
|
||||
- `type` is the type of change (e.g., `feat` for new features, `fix` for bug fixes, `docs` for documentation changes, etc.)
|
||||
- `scope` is an optional area of the codebase that your change affects (e.g., `api`, `ui`, `docs`, etc.)
|
||||
- `description` is a brief summary of the change
|
||||
- **Submit a pull request**: Once your changes are ready, submit a pull request to the main repository. Provide a clear description of your changes and reference any related issues. The project maintainers will review your pull request and may provide feedback or request changes before it can be merged.
|
||||
- **Be responsive to feedback**: If the maintainers request changes or provide feedback on your pull request, please respond in a timely manner. Stale pull requests may be closed if there is no activity for an extended period.
|
||||
- **Follow the project's code of conduct**: Please adhere to the project's code of conduct when interacting with maintainers and other contributors. Be respectful and considerate in your communications.
|
||||
- **No guarantee of acceptance**: The project is community-driven, and all contributions are appreciated, but acceptance is not guaranteed. The maintainers will evaluate each contribution based on its quality, relevance, and alignment with the project's goals.
|
||||
- **Thank you for contributing to Project N.O.M.A.D.!** Your efforts help make this project better for everyone.
|
||||
|
||||
### Versioning
|
||||
This project uses semantic versioning. The version is managed in the root `package.json`
|
||||
and automatically updated by semantic-release. For simplicity's sake, the "project-nomad" image
|
||||
uses the same version defined there instead of the version in `admin/package.json` (stays at 0.0.0), as it's the only published image derived from the code.
|
||||
|
||||
### Release Notes
|
||||
Human-readable release notes live in [`admin/docs/release-notes.md`](admin/docs/release-notes.md) and are displayed in the Command Center's built-in documentation.
|
||||
|
||||
When working on changes, add a summary to the `## Unreleased` section at the top of that file under the appropriate heading:
|
||||
|
||||
- **Features** — new user-facing capabilities
|
||||
- **Bug Fixes** — corrections to existing behavior
|
||||
- **Improvements** — enhancements, refactors, docs, or dependency updates
|
||||
|
||||
Use the format `- **Area**: Description` to stay consistent with existing entries. When a release is triggered, CI automatically stamps the version and date, commits the update, and pushes the content to the GitHub release.
|
||||
Contributions are welcome and appreciated! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on how to contribute to the project.
|
||||
|
||||
## Community & Resources
|
||||
|
||||
- **Website:** [www.projectnomad.us](https://www.projectnomad.us) - Learn more about the project
|
||||
- **Discord:** [Join the Community](https://discord.com/invite/crosstalksolutions) - Get help, share your builds, and connect with other NOMAD users
|
||||
- **Benchmark Leaderboard:** [benchmark.projectnomad.us](https://benchmark.projectnomad.us) - See how your hardware stacks up against other NOMAD builds
|
||||
- **Troubleshooting Guide:** [TROUBLESHOOTING.md](TROUBLESHOOTING.md) - Find solutions to common issues
|
||||
- **FAQ:** [FAQ.md](FAQ.md) - Find answers to frequently asked questions
|
||||
|
||||
## License
|
||||
|
||||
Project N.O.M.A.D. is licensed under the [Apache License 2.0](LICENSE).
|
||||
|
||||
## Helper Scripts
|
||||
Once installed, Project N.O.M.A.D. has a few helper scripts should you ever need to troubleshoot issues or perform maintenance that can't be done through the Command Center. All of these scripts are found in Project N.O.M.A.D.'s install directory, `/opt/project-nomad`
|
||||
|
|
@ -160,5 +143,5 @@ sudo bash /opt/project-nomad/update_nomad.sh
|
|||
|
||||
###### Uninstall Script - Need to start fresh? Use the uninstall script to make your life easy. Note: this cannot be undone!
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/master/install/uninstall_nomad.sh -o uninstall_nomad.sh && sudo bash uninstall_nomad.sh
|
||||
curl -fsSL https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/install/uninstall_nomad.sh -o uninstall_nomad.sh && sudo bash uninstall_nomad.sh
|
||||
```
|
||||
|
|
@ -2,7 +2,6 @@ import { inject } from '@adonisjs/core'
|
|||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import { ChatService } from '#services/chat_service'
|
||||
import { createSessionSchema, updateSessionSchema, addMessageSchema } from '#validators/chat'
|
||||
import { parseBoolean } from '../utils/misc.js'
|
||||
import KVStore from '#models/kv_store'
|
||||
import { SystemService } from '#services/system_service'
|
||||
import { SERVICE_NAMES } from '../../constants/service_names.js'
|
||||
|
|
@ -20,7 +19,7 @@ export default class ChatsController {
|
|||
const chatSuggestionsEnabled = await KVStore.getValue('chat.suggestionsEnabled')
|
||||
return inertia.render('chat', {
|
||||
settings: {
|
||||
chatSuggestionsEnabled: parseBoolean(chatSuggestionsEnabled),
|
||||
chatSuggestionsEnabled: chatSuggestionsEnabled ?? false,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { CollectionUpdateService } from '#services/collection_update_service'
|
||||
import {
|
||||
assertNotPrivateUrl,
|
||||
applyContentUpdateValidator,
|
||||
applyAllContentUpdatesValidator,
|
||||
} from '#validators/common'
|
||||
|
|
@ -13,12 +14,16 @@ export default class CollectionUpdatesController {
|
|||
|
||||
async applyUpdate({ request }: HttpContext) {
|
||||
const update = await request.validateUsing(applyContentUpdateValidator)
|
||||
assertNotPrivateUrl(update.download_url)
|
||||
const service = new CollectionUpdateService()
|
||||
return await service.applyUpdate(update)
|
||||
}
|
||||
|
||||
async applyAllUpdates({ request }: HttpContext) {
|
||||
const { updates } = await request.validateUsing(applyAllContentUpdatesValidator)
|
||||
for (const update of updates) {
|
||||
assertNotPrivateUrl(update.download_url)
|
||||
}
|
||||
const service = new CollectionUpdateService()
|
||||
return await service.applyAllUpdates(updates)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,4 +15,9 @@ export default class DownloadsController {
|
|||
const payload = await request.validateUsing(downloadJobsByFiletypeSchema)
|
||||
return this.downloadService.listDownloadJobs(payload.params.filetype)
|
||||
}
|
||||
|
||||
async removeJob({ params }: HttpContext) {
|
||||
await this.downloadService.removeFailedJob(params.jobId)
|
||||
return { success: true }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { MapService } from '#services/map_service'
|
||||
import {
|
||||
assertNotPrivateUrl,
|
||||
downloadCollectionValidator,
|
||||
filenameParamValidator,
|
||||
remoteDownloadValidator,
|
||||
|
|
@ -25,12 +26,14 @@ export default class MapsController {
|
|||
|
||||
async downloadBaseAssets({ request }: HttpContext) {
|
||||
const payload = await request.validateUsing(remoteDownloadValidatorOptional)
|
||||
if (payload.url) assertNotPrivateUrl(payload.url)
|
||||
await this.mapService.downloadBaseAssets(payload.url)
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
async downloadRemote({ request }: HttpContext) {
|
||||
const payload = await request.validateUsing(remoteDownloadValidator)
|
||||
assertNotPrivateUrl(payload.url)
|
||||
const filename = await this.mapService.downloadRemote(payload.url)
|
||||
return {
|
||||
message: 'Download started successfully',
|
||||
|
|
@ -52,6 +55,7 @@ export default class MapsController {
|
|||
// For providing a "preflight" check in the UI before actually starting a background download
|
||||
async downloadRemotePreflight({ request }: HttpContext) {
|
||||
const payload = await request.validateUsing(remoteDownloadValidator)
|
||||
assertNotPrivateUrl(payload.url)
|
||||
const info = await this.mapService.downloadRemotePreflight(payload.url)
|
||||
return info
|
||||
}
|
||||
|
|
@ -79,7 +83,7 @@ export default class MapsController {
|
|||
})
|
||||
}
|
||||
|
||||
const styles = await this.mapService.generateStylesJSON(request.host())
|
||||
const styles = await this.mapService.generateStylesJSON(request.host(), request.protocol())
|
||||
return response.json(styles)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,16 +1,18 @@
|
|||
import { ChatService } from '#services/chat_service'
|
||||
import { OllamaService } from '#services/ollama_service'
|
||||
import { RagService } from '#services/rag_service'
|
||||
import { modelNameSchema } from '#validators/download'
|
||||
import { chatSchema, getAvailableModelsSchema } from '#validators/ollama'
|
||||
import { inject } from '@adonisjs/core'
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import { SYSTEM_PROMPTS } from '../../constants/ollama.js'
|
||||
import { DEFAULT_QUERY_REWRITE_MODEL, RAG_CONTEXT_LIMITS, SYSTEM_PROMPTS } from '../../constants/ollama.js'
|
||||
import logger from '@adonisjs/core/services/logger'
|
||||
import type { Message } from 'ollama'
|
||||
|
||||
@inject()
|
||||
export default class OllamaController {
|
||||
constructor(
|
||||
private chatService: ChatService,
|
||||
private ollamaService: OllamaService,
|
||||
private ragService: RagService
|
||||
) { }
|
||||
|
|
@ -21,86 +23,152 @@ export default class OllamaController {
|
|||
sort: reqData.sort,
|
||||
recommendedOnly: reqData.recommendedOnly,
|
||||
query: reqData.query || null,
|
||||
limit: reqData.limit || 15,
|
||||
force: reqData.force,
|
||||
})
|
||||
}
|
||||
|
||||
async chat({ request, response }: HttpContext) {
|
||||
const reqData = await request.validateUsing(chatSchema)
|
||||
|
||||
// If there are no system messages in the chat inject system prompts
|
||||
const hasSystemMessage = reqData.messages.some((msg) => msg.role === 'system')
|
||||
if (!hasSystemMessage) {
|
||||
const systemPrompt = {
|
||||
role: 'system' as const,
|
||||
content: SYSTEM_PROMPTS.default,
|
||||
}
|
||||
logger.debug('[OllamaController] Injecting system prompt')
|
||||
reqData.messages.unshift(systemPrompt)
|
||||
}
|
||||
|
||||
// Query rewriting for better RAG retrieval with manageable context
|
||||
// Will return user's latest message if no rewriting is needed
|
||||
const rewrittenQuery = await this.rewriteQueryWithContext(
|
||||
reqData.messages,
|
||||
reqData.model
|
||||
)
|
||||
|
||||
logger.debug(`[OllamaController] Rewritten query for RAG: "${rewrittenQuery}"`)
|
||||
if (rewrittenQuery) {
|
||||
const relevantDocs = await this.ragService.searchSimilarDocuments(
|
||||
rewrittenQuery,
|
||||
5, // Top 5 most relevant chunks
|
||||
0.3 // Minimum similarity score of 0.3
|
||||
)
|
||||
|
||||
logger.debug(`[RAG] Retrieved ${relevantDocs.length} relevant documents for query: "${rewrittenQuery}"`)
|
||||
|
||||
// If relevant context is found, inject as a system message
|
||||
if (relevantDocs.length > 0) {
|
||||
const contextText = relevantDocs
|
||||
.map((doc, idx) => `[Context ${idx + 1}] (Relevance: ${(doc.score * 100).toFixed(1)}%)\n${doc.text}`)
|
||||
.join('\n\n')
|
||||
|
||||
const systemMessage = {
|
||||
role: 'system' as const,
|
||||
content: SYSTEM_PROMPTS.rag_context(contextText),
|
||||
}
|
||||
|
||||
// Insert system message at the beginning (after any existing system messages)
|
||||
const firstNonSystemIndex = reqData.messages.findIndex((msg) => msg.role !== 'system')
|
||||
const insertIndex = firstNonSystemIndex === -1 ? 0 : firstNonSystemIndex
|
||||
reqData.messages.splice(insertIndex, 0, systemMessage)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the model supports "thinking" capability for enhanced response generation
|
||||
// If gpt-oss model, it requires a text param for "think" https://docs.ollama.com/api/chat
|
||||
const thinkingCapability = await this.ollamaService.checkModelHasThinking(reqData.model)
|
||||
const think: boolean | 'medium' = thinkingCapability ? (reqData.model.startsWith('gpt-oss') ? 'medium' : true) : false
|
||||
|
||||
// Flush SSE headers immediately so the client connection is open while
|
||||
// pre-processing (query rewriting, RAG lookup) runs in the background.
|
||||
if (reqData.stream) {
|
||||
logger.debug(`[OllamaController] Initiating streaming response for model: "${reqData.model}" with think: ${think}`)
|
||||
// SSE streaming path
|
||||
response.response.setHeader('Content-Type', 'text/event-stream')
|
||||
response.response.setHeader('Cache-Control', 'no-cache')
|
||||
response.response.setHeader('Connection', 'keep-alive')
|
||||
response.response.flushHeaders()
|
||||
|
||||
try {
|
||||
const stream = await this.ollamaService.chatStream({ ...reqData, think })
|
||||
for await (const chunk of stream) {
|
||||
response.response.write(`data: ${JSON.stringify(chunk)}\n\n`)
|
||||
}
|
||||
} catch (error) {
|
||||
response.response.write(`data: ${JSON.stringify({ error: true })}\n\n`)
|
||||
} finally {
|
||||
response.response.end()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Non-streaming (legacy) path
|
||||
return await this.ollamaService.chat({ ...reqData, think })
|
||||
try {
|
||||
// If there are no system messages in the chat inject system prompts
|
||||
const hasSystemMessage = reqData.messages.some((msg) => msg.role === 'system')
|
||||
if (!hasSystemMessage) {
|
||||
const systemPrompt = {
|
||||
role: 'system' as const,
|
||||
content: SYSTEM_PROMPTS.default,
|
||||
}
|
||||
logger.debug('[OllamaController] Injecting system prompt')
|
||||
reqData.messages.unshift(systemPrompt)
|
||||
}
|
||||
|
||||
// Query rewriting for better RAG retrieval with manageable context
|
||||
// Will return user's latest message if no rewriting is needed
|
||||
const rewrittenQuery = await this.rewriteQueryWithContext(reqData.messages)
|
||||
|
||||
logger.debug(`[OllamaController] Rewritten query for RAG: "${rewrittenQuery}"`)
|
||||
if (rewrittenQuery) {
|
||||
const relevantDocs = await this.ragService.searchSimilarDocuments(
|
||||
rewrittenQuery,
|
||||
5, // Top 5 most relevant chunks
|
||||
0.3 // Minimum similarity score of 0.3
|
||||
)
|
||||
|
||||
logger.debug(`[RAG] Retrieved ${relevantDocs.length} relevant documents for query: "${rewrittenQuery}"`)
|
||||
|
||||
// If relevant context is found, inject as a system message with adaptive limits
|
||||
if (relevantDocs.length > 0) {
|
||||
// Determine context budget based on model size
|
||||
const { maxResults, maxTokens } = this.getContextLimitsForModel(reqData.model)
|
||||
let trimmedDocs = relevantDocs.slice(0, maxResults)
|
||||
|
||||
// Apply token cap if set (estimate ~4 chars per token)
|
||||
// Always include the first (most relevant) result — the cap only gates subsequent results
|
||||
if (maxTokens > 0) {
|
||||
const charCap = maxTokens * 4
|
||||
let totalChars = 0
|
||||
trimmedDocs = trimmedDocs.filter((doc, idx) => {
|
||||
totalChars += doc.text.length
|
||||
return idx === 0 || totalChars <= charCap
|
||||
})
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`[RAG] Injecting ${trimmedDocs.length}/${relevantDocs.length} results (model: ${reqData.model}, maxResults: ${maxResults}, maxTokens: ${maxTokens || 'unlimited'})`
|
||||
)
|
||||
|
||||
const contextText = trimmedDocs
|
||||
.map((doc, idx) => `[Context ${idx + 1}] (Relevance: ${(doc.score * 100).toFixed(1)}%)\n${doc.text}`)
|
||||
.join('\n\n')
|
||||
|
||||
const systemMessage = {
|
||||
role: 'system' as const,
|
||||
content: SYSTEM_PROMPTS.rag_context(contextText),
|
||||
}
|
||||
|
||||
// Insert system message at the beginning (after any existing system messages)
|
||||
const firstNonSystemIndex = reqData.messages.findIndex((msg) => msg.role !== 'system')
|
||||
const insertIndex = firstNonSystemIndex === -1 ? 0 : firstNonSystemIndex
|
||||
reqData.messages.splice(insertIndex, 0, systemMessage)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the model supports "thinking" capability for enhanced response generation
|
||||
// If gpt-oss model, it requires a text param for "think" https://docs.ollama.com/api/chat
|
||||
const thinkingCapability = await this.ollamaService.checkModelHasThinking(reqData.model)
|
||||
const think: boolean | 'medium' = thinkingCapability ? (reqData.model.startsWith('gpt-oss') ? 'medium' : true) : false
|
||||
|
||||
// Separate sessionId from the Ollama request payload — Ollama rejects unknown fields
|
||||
const { sessionId, ...ollamaRequest } = reqData
|
||||
|
||||
// Save user message to DB before streaming if sessionId provided
|
||||
let userContent: string | null = null
|
||||
if (sessionId) {
|
||||
const lastUserMsg = [...reqData.messages].reverse().find((m) => m.role === 'user')
|
||||
if (lastUserMsg) {
|
||||
userContent = lastUserMsg.content
|
||||
await this.chatService.addMessage(sessionId, 'user', userContent)
|
||||
}
|
||||
}
|
||||
|
||||
if (reqData.stream) {
|
||||
logger.debug(`[OllamaController] Initiating streaming response for model: "${reqData.model}" with think: ${think}`)
|
||||
// Headers already flushed above
|
||||
const stream = await this.ollamaService.chatStream({ ...ollamaRequest, think })
|
||||
let fullContent = ''
|
||||
for await (const chunk of stream) {
|
||||
if (chunk.message?.content) {
|
||||
fullContent += chunk.message.content
|
||||
}
|
||||
response.response.write(`data: ${JSON.stringify(chunk)}\n\n`)
|
||||
}
|
||||
response.response.end()
|
||||
|
||||
// Save assistant message and optionally generate title
|
||||
if (sessionId && fullContent) {
|
||||
await this.chatService.addMessage(sessionId, 'assistant', fullContent)
|
||||
const messageCount = await this.chatService.getMessageCount(sessionId)
|
||||
if (messageCount <= 2 && userContent) {
|
||||
this.chatService.generateTitle(sessionId, userContent, fullContent).catch((err) => {
|
||||
logger.error(`[OllamaController] Title generation failed: ${err instanceof Error ? err.message : err}`)
|
||||
})
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Non-streaming (legacy) path
|
||||
const result = await this.ollamaService.chat({ ...ollamaRequest, think })
|
||||
|
||||
if (sessionId && result?.message?.content) {
|
||||
await this.chatService.addMessage(sessionId, 'assistant', result.message.content)
|
||||
const messageCount = await this.chatService.getMessageCount(sessionId)
|
||||
if (messageCount <= 2 && userContent) {
|
||||
this.chatService.generateTitle(sessionId, userContent, result.message.content).catch((err) => {
|
||||
logger.error(`[OllamaController] Title generation failed: ${err instanceof Error ? err.message : err}`)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
if (reqData.stream) {
|
||||
response.response.write(`data: ${JSON.stringify({ error: true })}\n\n`)
|
||||
response.response.end()
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async deleteModel({ request }: HttpContext) {
|
||||
|
|
@ -125,18 +193,37 @@ export default class OllamaController {
|
|||
return await this.ollamaService.getModels()
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines RAG context limits based on model size extracted from the model name.
|
||||
* Parses size indicators like "1b", "3b", "8b", "70b" from model names/tags.
|
||||
*/
|
||||
private getContextLimitsForModel(modelName: string): { maxResults: number; maxTokens: number } {
|
||||
// Extract parameter count from model name (e.g., "llama3.2:3b", "qwen2.5:1.5b", "gemma:7b")
|
||||
const sizeMatch = modelName.match(/(\d+\.?\d*)[bB]/)
|
||||
const paramBillions = sizeMatch ? parseFloat(sizeMatch[1]) : 8 // default to 8B if unknown
|
||||
|
||||
for (const tier of RAG_CONTEXT_LIMITS) {
|
||||
if (paramBillions <= tier.maxParams) {
|
||||
return { maxResults: tier.maxResults, maxTokens: tier.maxTokens }
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: no limits
|
||||
return { maxResults: 5, maxTokens: 0 }
|
||||
}
|
||||
|
||||
private async rewriteQueryWithContext(
|
||||
messages: Message[],
|
||||
model: string
|
||||
messages: Message[]
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
// Get recent conversation history (last 6 messages for 3 turns)
|
||||
const recentMessages = messages.slice(-6)
|
||||
|
||||
// If there's only one user message, no rewriting needed
|
||||
// Skip rewriting for short conversations. Rewriting adds latency with
|
||||
// little RAG benefit until there is enough context to matter.
|
||||
const userMessages = recentMessages.filter(msg => msg.role === 'user')
|
||||
if (userMessages.length <= 1) {
|
||||
return userMessages[0]?.content || null
|
||||
if (userMessages.length <= 2) {
|
||||
return userMessages[userMessages.length - 1]?.content || null
|
||||
}
|
||||
|
||||
const conversationContext = recentMessages
|
||||
|
|
@ -150,8 +237,17 @@ export default class OllamaController {
|
|||
})
|
||||
.join('\n')
|
||||
|
||||
const installedModels = await this.ollamaService.getModels(true)
|
||||
const rewriteModelAvailable = installedModels?.some(model => model.name === DEFAULT_QUERY_REWRITE_MODEL)
|
||||
if (!rewriteModelAvailable) {
|
||||
logger.warn(`[RAG] Query rewrite model "${DEFAULT_QUERY_REWRITE_MODEL}" not available. Skipping query rewriting.`)
|
||||
const lastUserMessage = [...messages].reverse().find(msg => msg.role === 'user')
|
||||
return lastUserMessage?.content || null
|
||||
}
|
||||
|
||||
// FUTURE ENHANCEMENT: allow the user to specify which model to use for rewriting
|
||||
const response = await this.ollamaService.chat({
|
||||
model,
|
||||
model: DEFAULT_QUERY_REWRITE_MODEL,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import type { HttpContext } from '@adonisjs/core/http'
|
|||
import app from '@adonisjs/core/services/app'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { sanitizeFilename } from '../utils/fs.js'
|
||||
import { getJobStatusSchema } from '#validators/rag'
|
||||
import { deleteFileSchema, getJobStatusSchema } from '#validators/rag'
|
||||
|
||||
@inject()
|
||||
export default class RagController {
|
||||
|
|
@ -42,6 +42,11 @@ export default class RagController {
|
|||
})
|
||||
}
|
||||
|
||||
public async getActiveJobs({ response }: HttpContext) {
|
||||
const jobs = await EmbedFileJob.listActiveJobs()
|
||||
return response.status(200).json(jobs)
|
||||
}
|
||||
|
||||
public async getJobStatus({ request, response }: HttpContext) {
|
||||
const reqData = await request.validateUsing(getJobStatusSchema)
|
||||
|
||||
|
|
@ -60,6 +65,15 @@ export default class RagController {
|
|||
return response.status(200).json({ files })
|
||||
}
|
||||
|
||||
public async deleteFile({ request, response }: HttpContext) {
|
||||
const { source } = await request.validateUsing(deleteFileSchema)
|
||||
const result = await this.ragService.deleteFileBySource(source)
|
||||
if (!result.success) {
|
||||
return response.status(500).json({ error: result.message })
|
||||
}
|
||||
return response.status(200).json({ message: result.message })
|
||||
}
|
||||
|
||||
public async scanAndSync({ response }: HttpContext) {
|
||||
try {
|
||||
const syncResult = await this.ragService.scanAndSyncStorage()
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { SystemService } from '#services/system_service';
|
|||
import { updateSettingSchema } from '#validators/settings';
|
||||
import { inject } from '@adonisjs/core';
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import { parseBoolean } from '../utils/misc.js';
|
||||
import type { KVStoreKey } from '../../types/kv_store.js';
|
||||
|
||||
@inject()
|
||||
export default class SettingsController {
|
||||
|
|
@ -39,6 +39,10 @@ export default class SettingsController {
|
|||
return inertia.render('settings/legal');
|
||||
}
|
||||
|
||||
async support({ inertia }: HttpContext) {
|
||||
return inertia.render('settings/support');
|
||||
}
|
||||
|
||||
async maps({ inertia }: HttpContext) {
|
||||
const baseAssetsCheck = await this.mapService.ensureBaseAssets();
|
||||
const regionFiles = await this.mapService.listRegions();
|
||||
|
|
@ -51,15 +55,17 @@ export default class SettingsController {
|
|||
}
|
||||
|
||||
async models({ inertia }: HttpContext) {
|
||||
const availableModels = await this.ollamaService.getAvailableModels({ sort: 'pulls', recommendedOnly: false, query: null });
|
||||
const availableModels = await this.ollamaService.getAvailableModels({ sort: 'pulls', recommendedOnly: false, query: null, limit: 15 });
|
||||
const installedModels = await this.ollamaService.getModels();
|
||||
const chatSuggestionsEnabled = await KVStore.getValue('chat.suggestionsEnabled')
|
||||
const aiAssistantCustomName = await KVStore.getValue('ai.assistantCustomName')
|
||||
return inertia.render('settings/models', {
|
||||
models: {
|
||||
availableModels: availableModels || [],
|
||||
availableModels: availableModels?.models || [],
|
||||
installedModels: installedModels || [],
|
||||
settings: {
|
||||
chatSuggestionsEnabled: parseBoolean(chatSuggestionsEnabled)
|
||||
chatSuggestionsEnabled: chatSuggestionsEnabled ?? false,
|
||||
aiAssistantCustomName: aiAssistantCustomName ?? '',
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -98,7 +104,7 @@ export default class SettingsController {
|
|||
|
||||
async getSetting({ request, response }: HttpContext) {
|
||||
const key = request.qs().key;
|
||||
const value = await KVStore.getValue(key);
|
||||
const value = await KVStore.getValue(key as KVStoreKey);
|
||||
return response.status(200).send({ key, value });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { DockerService } from '#services/docker_service';
|
||||
import { SystemService } from '#services/system_service'
|
||||
import { SystemUpdateService } from '#services/system_update_service'
|
||||
import { affectServiceValidator, checkLatestVersionValidator, installServiceValidator, subscribeToReleaseNotesValidator } from '#validators/system';
|
||||
import { ContainerRegistryService } from '#services/container_registry_service'
|
||||
import { CheckServiceUpdatesJob } from '#jobs/check_service_updates_job'
|
||||
import { affectServiceValidator, checkLatestVersionValidator, installServiceValidator, subscribeToReleaseNotesValidator, updateServiceValidator } from '#validators/system';
|
||||
import { inject } from '@adonisjs/core'
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
|
||||
|
|
@ -10,7 +12,8 @@ export default class SystemController {
|
|||
constructor(
|
||||
private systemService: SystemService,
|
||||
private dockerService: DockerService,
|
||||
private systemUpdateService: SystemUpdateService
|
||||
private systemUpdateService: SystemUpdateService,
|
||||
private containerRegistryService: ContainerRegistryService
|
||||
) { }
|
||||
|
||||
async getInternetStatus({ }: HttpContext) {
|
||||
|
|
@ -32,7 +35,7 @@ export default class SystemController {
|
|||
if (result.success) {
|
||||
response.send({ success: true, message: result.message });
|
||||
} else {
|
||||
response.status(400).send({ error: result.message });
|
||||
response.status(400).send({ success: false, message: result.message });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -104,9 +107,75 @@ export default class SystemController {
|
|||
response.send({ logs });
|
||||
}
|
||||
|
||||
|
||||
|
||||
async subscribeToReleaseNotes({ request }: HttpContext) {
|
||||
const reqData = await request.validateUsing(subscribeToReleaseNotesValidator);
|
||||
return await this.systemService.subscribeToReleaseNotes(reqData.email);
|
||||
}
|
||||
|
||||
async getDebugInfo({}: HttpContext) {
|
||||
const debugInfo = await this.systemService.getDebugInfo()
|
||||
return { debugInfo }
|
||||
}
|
||||
|
||||
async checkServiceUpdates({ response }: HttpContext) {
|
||||
await CheckServiceUpdatesJob.dispatch()
|
||||
response.send({ success: true, message: 'Service update check dispatched' })
|
||||
}
|
||||
|
||||
async getAvailableVersions({ params, response }: HttpContext) {
|
||||
const serviceName = params.name
|
||||
const service = await (await import('#models/service')).default
|
||||
.query()
|
||||
.where('service_name', serviceName)
|
||||
.where('installed', true)
|
||||
.first()
|
||||
|
||||
if (!service) {
|
||||
return response.status(404).send({ error: `Service ${serviceName} not found or not installed` })
|
||||
}
|
||||
|
||||
try {
|
||||
const hostArch = await this.getHostArch()
|
||||
const updates = await this.containerRegistryService.getAvailableUpdates(
|
||||
service.container_image,
|
||||
hostArch,
|
||||
service.source_repo
|
||||
)
|
||||
response.send({ versions: updates })
|
||||
} catch (error) {
|
||||
response.status(500).send({ error: `Failed to fetch versions: ${error.message}` })
|
||||
}
|
||||
}
|
||||
|
||||
async updateService({ request, response }: HttpContext) {
|
||||
const payload = await request.validateUsing(updateServiceValidator)
|
||||
const result = await this.dockerService.updateContainer(
|
||||
payload.service_name,
|
||||
payload.target_version
|
||||
)
|
||||
|
||||
if (result.success) {
|
||||
response.send({ success: true, message: result.message })
|
||||
} else {
|
||||
response.status(400).send({ error: result.message })
|
||||
}
|
||||
}
|
||||
|
||||
private async getHostArch(): Promise<string> {
|
||||
try {
|
||||
const info = await this.dockerService.docker.info()
|
||||
const arch = info.Architecture || ''
|
||||
const archMap: Record<string, string> = {
|
||||
x86_64: 'amd64',
|
||||
aarch64: 'arm64',
|
||||
armv7l: 'arm',
|
||||
amd64: 'amd64',
|
||||
arm64: 'arm64',
|
||||
}
|
||||
return archMap[arch] || arch.toLowerCase()
|
||||
} catch {
|
||||
return 'amd64'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { ZimService } from '#services/zim_service'
|
||||
import {
|
||||
assertNotPrivateUrl,
|
||||
downloadCategoryTierValidator,
|
||||
filenameParamValidator,
|
||||
remoteDownloadWithMetadataValidator,
|
||||
|
|
@ -25,6 +26,7 @@ export default class ZimController {
|
|||
|
||||
async downloadRemote({ request }: HttpContext) {
|
||||
const payload = await request.validateUsing(remoteDownloadWithMetadataValidator)
|
||||
assertNotPrivateUrl(payload.url)
|
||||
const { filename, jobId } = await this.zimService.downloadRemote(payload.url)
|
||||
|
||||
return {
|
||||
|
|
|
|||
134
admin/app/jobs/check_service_updates_job.ts
Normal file
134
admin/app/jobs/check_service_updates_job.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import { Job } from 'bullmq'
|
||||
import { QueueService } from '#services/queue_service'
|
||||
import { DockerService } from '#services/docker_service'
|
||||
import { ContainerRegistryService } from '#services/container_registry_service'
|
||||
import Service from '#models/service'
|
||||
import logger from '@adonisjs/core/services/logger'
|
||||
import transmit from '@adonisjs/transmit/services/main'
|
||||
import { BROADCAST_CHANNELS } from '../../constants/broadcast.js'
|
||||
import { DateTime } from 'luxon'
|
||||
|
||||
export class CheckServiceUpdatesJob {
|
||||
static get queue() {
|
||||
return 'service-updates'
|
||||
}
|
||||
|
||||
static get key() {
|
||||
return 'check-service-updates'
|
||||
}
|
||||
|
||||
async handle(_job: Job) {
|
||||
logger.info('[CheckServiceUpdatesJob] Checking for service updates...')
|
||||
|
||||
const dockerService = new DockerService()
|
||||
const registryService = new ContainerRegistryService()
|
||||
|
||||
// Determine host architecture
|
||||
const hostArch = await this.getHostArch(dockerService)
|
||||
|
||||
const installedServices = await Service.query().where('installed', true)
|
||||
let updatesFound = 0
|
||||
|
||||
for (const service of installedServices) {
|
||||
try {
|
||||
const updates = await registryService.getAvailableUpdates(
|
||||
service.container_image,
|
||||
hostArch,
|
||||
service.source_repo
|
||||
)
|
||||
|
||||
const latestUpdate = updates.length > 0 ? updates[0].tag : null
|
||||
|
||||
service.available_update_version = latestUpdate
|
||||
service.update_checked_at = DateTime.now()
|
||||
await service.save()
|
||||
|
||||
if (latestUpdate) {
|
||||
updatesFound++
|
||||
logger.info(
|
||||
`[CheckServiceUpdatesJob] Update available for ${service.service_name}: ${service.container_image} → ${latestUpdate}`
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[CheckServiceUpdatesJob] Failed to check updates for ${service.service_name}: ${error.message}`
|
||||
)
|
||||
// Continue checking other services
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[CheckServiceUpdatesJob] Completed. ${updatesFound} update(s) found for ${installedServices.length} service(s).`
|
||||
)
|
||||
|
||||
// Broadcast completion so the frontend can refresh
|
||||
transmit.broadcast(BROADCAST_CHANNELS.SERVICE_UPDATES, {
|
||||
status: 'completed',
|
||||
updatesFound,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
|
||||
return { updatesFound }
|
||||
}
|
||||
|
||||
private async getHostArch(dockerService: DockerService): Promise<string> {
|
||||
try {
|
||||
const info = await dockerService.docker.info()
|
||||
const arch = info.Architecture || ''
|
||||
|
||||
// Map Docker architecture names to OCI names
|
||||
const archMap: Record<string, string> = {
|
||||
x86_64: 'amd64',
|
||||
aarch64: 'arm64',
|
||||
armv7l: 'arm',
|
||||
amd64: 'amd64',
|
||||
arm64: 'arm64',
|
||||
}
|
||||
|
||||
return archMap[arch] || arch.toLowerCase()
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[CheckServiceUpdatesJob] Could not detect host architecture: ${error.message}. Defaulting to amd64.`
|
||||
)
|
||||
return 'amd64'
|
||||
}
|
||||
}
|
||||
|
||||
static async scheduleNightly() {
|
||||
const queueService = new QueueService()
|
||||
const queue = queueService.getQueue(this.queue)
|
||||
|
||||
await queue.upsertJobScheduler(
|
||||
'nightly-service-update-check',
|
||||
{ pattern: '0 3 * * *' },
|
||||
{
|
||||
name: this.key,
|
||||
opts: {
|
||||
removeOnComplete: { count: 7 },
|
||||
removeOnFail: { count: 5 },
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
logger.info('[CheckServiceUpdatesJob] Service update check scheduled with cron: 0 3 * * *')
|
||||
}
|
||||
|
||||
static async dispatch() {
|
||||
const queueService = new QueueService()
|
||||
const queue = queueService.getQueue(this.queue)
|
||||
|
||||
const job = await queue.add(
|
||||
this.key,
|
||||
{},
|
||||
{
|
||||
attempts: 3,
|
||||
backoff: { type: 'exponential', delay: 60000 },
|
||||
removeOnComplete: { count: 7 },
|
||||
removeOnFail: { count: 5 },
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(`[CheckServiceUpdatesJob] Dispatched ad-hoc service update check job ${job.id}`)
|
||||
return job
|
||||
}
|
||||
}
|
||||
|
|
@ -28,7 +28,7 @@ export class CheckUpdateJob {
|
|||
`[CheckUpdateJob] Update available: ${result.currentVersion} → ${result.latestVersion}`
|
||||
)
|
||||
} else {
|
||||
await KVStore.setValue('system.updateAvailable', "false")
|
||||
await KVStore.setValue('system.updateAvailable', false)
|
||||
logger.info(
|
||||
`[CheckUpdateJob] System is up to date (${result.currentVersion})`
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Job } from 'bullmq'
|
||||
import { Job, UnrecoverableError } from 'bullmq'
|
||||
import { QueueService } from '#services/queue_service'
|
||||
import { createHash } from 'crypto'
|
||||
import logger from '@adonisjs/core/services/logger'
|
||||
|
|
@ -63,6 +63,10 @@ export class DownloadModelJob {
|
|||
logger.error(
|
||||
`[DownloadModelJob] Failed to initiate download for model ${modelName}: ${result.message}`
|
||||
)
|
||||
// Don't retry errors that will never succeed (e.g., Ollama version too old)
|
||||
if (result.retryable === false) {
|
||||
throw new UnrecoverableError(result.message)
|
||||
}
|
||||
throw new Error(`Failed to initiate download for model: ${result.message}`)
|
||||
}
|
||||
|
||||
|
|
@ -85,6 +89,15 @@ export class DownloadModelJob {
|
|||
const queue = queueService.getQueue(this.queue)
|
||||
const jobId = this.getJobId(params.modelName)
|
||||
|
||||
// Clear any previous failed job so a fresh attempt can be dispatched
|
||||
const existing = await queue.getJob(jobId)
|
||||
if (existing) {
|
||||
const state = await existing.getState()
|
||||
if (state === 'failed') {
|
||||
await existing.remove()
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const job = await queue.add(this.key, params, {
|
||||
jobId,
|
||||
|
|
@ -104,9 +117,9 @@ export class DownloadModelJob {
|
|||
}
|
||||
} catch (error) {
|
||||
if (error.message.includes('job already exists')) {
|
||||
const existing = await queue.getJob(jobId)
|
||||
const active = await queue.getJob(jobId)
|
||||
return {
|
||||
job: existing,
|
||||
job: active,
|
||||
created: false,
|
||||
message: `Job already exists for model ${params.modelName}`,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Job } from 'bullmq'
|
||||
import { Job, UnrecoverableError } from 'bullmq'
|
||||
import { QueueService } from '#services/queue_service'
|
||||
import { EmbedJobWithProgress } from '../../types/rag.js'
|
||||
import { RagService } from '#services/rag_service'
|
||||
import { DockerService } from '#services/docker_service'
|
||||
import { OllamaService } from '#services/ollama_service'
|
||||
|
|
@ -41,7 +42,15 @@ export class EmbedFileJob {
|
|||
const ragService = new RagService(dockerService, ollamaService)
|
||||
|
||||
try {
|
||||
// Check if Ollama and Qdrant services are ready
|
||||
// Check if Ollama and Qdrant services are installed and ready
|
||||
// Use UnrecoverableError for "not installed" so BullMQ won't retry —
|
||||
// retrying 30x when the service doesn't exist just wastes Redis connections
|
||||
const ollamaUrl = await dockerService.getServiceURL('nomad_ollama')
|
||||
if (!ollamaUrl) {
|
||||
logger.warn('[EmbedFileJob] Ollama is not installed. Skipping embedding for: %s', fileName)
|
||||
throw new UnrecoverableError('Ollama service is not installed. Install AI Assistant to enable file embeddings.')
|
||||
}
|
||||
|
||||
const existingModels = await ollamaService.getModels()
|
||||
if (!existingModels) {
|
||||
logger.warn('[EmbedFileJob] Ollama service not ready yet. Will retry...')
|
||||
|
|
@ -50,14 +59,14 @@ export class EmbedFileJob {
|
|||
|
||||
const qdrantUrl = await dockerService.getServiceURL('nomad_qdrant')
|
||||
if (!qdrantUrl) {
|
||||
logger.warn('[EmbedFileJob] Qdrant service not ready yet. Will retry...')
|
||||
throw new Error('Qdrant service not ready yet')
|
||||
logger.warn('[EmbedFileJob] Qdrant is not installed. Skipping embedding for: %s', fileName)
|
||||
throw new UnrecoverableError('Qdrant service is not installed. Install AI Assistant to enable file embeddings.')
|
||||
}
|
||||
|
||||
logger.info(`[EmbedFileJob] Services ready. Processing file: ${fileName}`)
|
||||
|
||||
// Update progress starting
|
||||
await job.updateProgress(0)
|
||||
await job.updateProgress(5)
|
||||
await job.updateData({
|
||||
...job.data,
|
||||
status: 'processing',
|
||||
|
|
@ -66,13 +75,19 @@ export class EmbedFileJob {
|
|||
|
||||
logger.info(`[EmbedFileJob] Processing file: ${filePath}`)
|
||||
|
||||
// Progress callback: maps service-reported 0-100% into the 5-95% job range
|
||||
const onProgress = async (percent: number) => {
|
||||
await job.updateProgress(Math.min(95, Math.round(5 + percent * 0.9)))
|
||||
}
|
||||
|
||||
// Process and embed the file
|
||||
// Only allow deletion if explicitly marked as final batch
|
||||
const allowDeletion = job.data.isFinalBatch === true
|
||||
const result = await ragService.processAndEmbedFile(
|
||||
filePath,
|
||||
allowDeletion,
|
||||
batchOffset
|
||||
batchOffset,
|
||||
onProgress
|
||||
)
|
||||
|
||||
if (!result.success) {
|
||||
|
|
@ -156,6 +171,20 @@ export class EmbedFileJob {
|
|||
}
|
||||
}
|
||||
|
||||
static async listActiveJobs(): Promise<EmbedJobWithProgress[]> {
|
||||
const queueService = new QueueService()
|
||||
const queue = queueService.getQueue(this.queue)
|
||||
const jobs = await queue.getJobs(['waiting', 'active', 'delayed'])
|
||||
|
||||
return jobs.map((job) => ({
|
||||
jobId: job.id!.toString(),
|
||||
fileName: (job.data as EmbedFileJobParams).fileName,
|
||||
filePath: (job.data as EmbedFileJobParams).filePath,
|
||||
progress: typeof job.progress === 'number' ? job.progress : 0,
|
||||
status: ((job.data as any).status as string) ?? 'waiting',
|
||||
}))
|
||||
}
|
||||
|
||||
static async getByFilePath(filePath: string): Promise<Job | undefined> {
|
||||
const queueService = new QueueService()
|
||||
const queue = queueService.getQueue(this.queue)
|
||||
|
|
|
|||
|
|
@ -82,14 +82,17 @@ export class RunDownloadJob {
|
|||
const zimService = new ZimService(dockerService)
|
||||
await zimService.downloadRemoteSuccessCallback([url], true)
|
||||
|
||||
// Dispatch an embedding job for the downloaded ZIM file
|
||||
try {
|
||||
await EmbedFileJob.dispatch({
|
||||
fileName: url.split('/').pop() || '',
|
||||
filePath: filepath,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(`[RunDownloadJob] Error dispatching EmbedFileJob for URL ${url}:`, error)
|
||||
// Only dispatch embedding job if AI Assistant (Ollama) is installed
|
||||
const ollamaUrl = await dockerService.getServiceURL('nomad_ollama')
|
||||
if (ollamaUrl) {
|
||||
try {
|
||||
await EmbedFileJob.dispatch({
|
||||
fileName: url.split('/').pop() || '',
|
||||
filePath: filepath,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(`[RunDownloadJob] Error dispatching EmbedFileJob for URL ${url}:`, error)
|
||||
}
|
||||
}
|
||||
} else if (filetype === 'map') {
|
||||
const mapsService = new MapService()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { DateTime } from 'luxon'
|
||||
import { BaseModel, column, SnakeCaseNamingStrategy } from '@adonisjs/lucid/orm'
|
||||
import type { KVStoreKey, KVStoreValue } from '../../types/kv_store.js'
|
||||
import { KV_STORE_SCHEMA, type KVStoreKey, type KVStoreValue } from '../../types/kv_store.js'
|
||||
import { parseBoolean } from '../utils/misc.js'
|
||||
|
||||
/**
|
||||
* Generic key-value store model for storing various settings
|
||||
|
|
@ -17,7 +18,7 @@ export default class KVStore extends BaseModel {
|
|||
declare key: KVStoreKey
|
||||
|
||||
@column()
|
||||
declare value: KVStoreValue
|
||||
declare value: string | null
|
||||
|
||||
@column.dateTime({ autoCreate: true })
|
||||
declare created_at: DateTime
|
||||
|
|
@ -26,28 +27,38 @@ export default class KVStore extends BaseModel {
|
|||
declare updated_at: DateTime
|
||||
|
||||
/**
|
||||
* Get a setting value by key
|
||||
* Get a setting value by key, automatically deserializing to the correct type.
|
||||
*/
|
||||
static async getValue(key: KVStoreKey): Promise<KVStoreValue> {
|
||||
static async getValue<K extends KVStoreKey>(key: K): Promise<KVStoreValue<K> | null> {
|
||||
const setting = await this.findBy('key', key)
|
||||
if (!setting || setting.value === undefined || setting.value === null) {
|
||||
return null
|
||||
}
|
||||
if (typeof setting.value === 'string') {
|
||||
return setting.value
|
||||
}
|
||||
return String(setting.value)
|
||||
const raw = String(setting.value)
|
||||
return (KV_STORE_SCHEMA[key] === 'boolean' ? parseBoolean(raw) : raw) as KVStoreValue<K>
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a setting value by key (creates if not exists)
|
||||
* Set a setting value by key (creates if not exists), automatically serializing to string.
|
||||
*/
|
||||
static async setValue(key: KVStoreKey, value: KVStoreValue): Promise<KVStore> {
|
||||
const setting = await this.firstOrCreate({ key }, { key, value })
|
||||
if (setting.value !== value) {
|
||||
setting.value = value
|
||||
static async setValue<K extends KVStoreKey>(key: K, value: KVStoreValue<K>): Promise<KVStore> {
|
||||
const serialized = String(value)
|
||||
const setting = await this.firstOrCreate({ key }, { key, value: serialized })
|
||||
if (setting.value !== serialized) {
|
||||
setting.value = serialized
|
||||
await setting.save()
|
||||
}
|
||||
return setting
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear a setting value by key, storing null so getValue returns null.
|
||||
*/
|
||||
static async clearValue<K extends KVStoreKey>(key: K): Promise<void> {
|
||||
const setting = await this.findBy('key', key)
|
||||
if (setting && setting.value !== null) {
|
||||
setting.value = null
|
||||
await setting.save()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,6 +62,15 @@ export default class Service extends BaseModel {
|
|||
@column()
|
||||
declare metadata: string | null
|
||||
|
||||
@column()
|
||||
declare source_repo: string | null
|
||||
|
||||
@column()
|
||||
declare available_update_version: string | null
|
||||
|
||||
@column.dateTime()
|
||||
declare update_checked_at: DateTime | null
|
||||
|
||||
@column.dateTime({ autoCreate: true })
|
||||
declare created_at: DateTime
|
||||
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ import Dockerode from 'dockerode'
|
|||
// This provides basic protection against casual API abuse.
|
||||
// Note: Since NOMAD is open source, a determined attacker could extract this.
|
||||
// For stronger protection, see challenge-response authentication.
|
||||
const BENCHMARK_HMAC_SECRET = 'nomad-benchmark-v1-2026'
|
||||
const BENCHMARK_HMAC_SECRET = '778ba65d0bc0e23119e5ffce4b3716648a7d071f0a47ec3f'
|
||||
|
||||
// Re-export default weights for use in service
|
||||
const SCORE_WEIGHTS = {
|
||||
|
|
@ -571,10 +571,10 @@ export class BenchmarkService {
|
|||
*/
|
||||
private _normalizeScore(value: number, reference: number): number {
|
||||
if (value <= 0) return 0
|
||||
// Log scale: score = 50 * (1 + log2(value/reference))
|
||||
// This gives 50 at reference value, scales logarithmically
|
||||
// Log scale with widened range: dividing log2 by 3 prevents scores from
|
||||
// clamping to 0% for below-average hardware. Gives 50% at reference value.
|
||||
const ratio = value / reference
|
||||
const score = 50 * (1 + Math.log2(Math.max(0.01, ratio)))
|
||||
const score = 50 * (1 + Math.log2(Math.max(0.01, ratio)) / 3)
|
||||
return Math.min(100, Math.max(0, score)) / 100
|
||||
}
|
||||
|
||||
|
|
@ -583,9 +583,9 @@ export class BenchmarkService {
|
|||
*/
|
||||
private _normalizeScoreInverse(value: number, reference: number): number {
|
||||
if (value <= 0) return 1
|
||||
// Inverse: lower values = higher scores
|
||||
// Inverse: lower values = higher scores, with widened log range
|
||||
const ratio = reference / value
|
||||
const score = 50 * (1 + Math.log2(Math.max(0.01, ratio)))
|
||||
const score = 50 * (1 + Math.log2(Math.max(0.01, ratio)) / 3)
|
||||
return Math.min(100, Math.max(0, score)) / 100
|
||||
}
|
||||
|
||||
|
|
@ -619,6 +619,7 @@ export class BenchmarkService {
|
|||
const eventsMatch = output.match(/events per second:\s*([\d.]+)/i)
|
||||
const totalTimeMatch = output.match(/total time:\s*([\d.]+)s/i)
|
||||
const totalEventsMatch = output.match(/total number of events:\s*(\d+)/i)
|
||||
logger.debug(`[BenchmarkService] CPU output parsing - events/s: ${eventsMatch?.[1]}, total_time: ${totalTimeMatch?.[1]}, total_events: ${totalEventsMatch?.[1]}`)
|
||||
|
||||
return {
|
||||
events_per_second: eventsMatch ? parseFloat(eventsMatch[1]) : 0,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import logger from '@adonisjs/core/services/logger'
|
|||
import { DateTime } from 'luxon'
|
||||
import { inject } from '@adonisjs/core'
|
||||
import { OllamaService } from './ollama_service.js'
|
||||
import { SYSTEM_PROMPTS } from '../../constants/ollama.js'
|
||||
import { DEFAULT_QUERY_REWRITE_MODEL, SYSTEM_PROMPTS } from '../../constants/ollama.js'
|
||||
import { toTitleCase } from '../utils/misc.js'
|
||||
|
||||
@inject()
|
||||
|
|
@ -220,6 +220,59 @@ export class ChatService {
|
|||
}
|
||||
}
|
||||
|
||||
async getMessageCount(sessionId: number): Promise<number> {
|
||||
try {
|
||||
const count = await ChatMessage.query().where('session_id', sessionId).count('* as total')
|
||||
return Number(count[0].$extras.total)
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[ChatService] Failed to get message count for session ${sessionId}: ${error instanceof Error ? error.message : error}`
|
||||
)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
async generateTitle(sessionId: number, userMessage: string, assistantMessage: string) {
|
||||
try {
|
||||
const models = await this.ollamaService.getModels()
|
||||
const titleModelAvailable = models?.some((m) => m.name === DEFAULT_QUERY_REWRITE_MODEL)
|
||||
|
||||
let title: string
|
||||
|
||||
if (!titleModelAvailable) {
|
||||
title = userMessage.slice(0, 57) + (userMessage.length > 57 ? '...' : '')
|
||||
} else {
|
||||
const response = await this.ollamaService.chat({
|
||||
model: DEFAULT_QUERY_REWRITE_MODEL,
|
||||
messages: [
|
||||
{ role: 'system', content: SYSTEM_PROMPTS.title_generation },
|
||||
{ role: 'user', content: userMessage },
|
||||
{ role: 'assistant', content: assistantMessage },
|
||||
],
|
||||
})
|
||||
|
||||
title = response?.message?.content?.trim()
|
||||
if (!title) {
|
||||
title = userMessage.slice(0, 57) + (userMessage.length > 57 ? '...' : '')
|
||||
}
|
||||
}
|
||||
|
||||
await this.updateSession(sessionId, { title })
|
||||
logger.info(`[ChatService] Generated title for session ${sessionId}: "${title}"`)
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[ChatService] Failed to generate title for session ${sessionId}: ${error instanceof Error ? error.message : error}`
|
||||
)
|
||||
// Fall back to truncated user message
|
||||
try {
|
||||
const fallbackTitle = userMessage.slice(0, 57) + (userMessage.length > 57 ? '...' : '')
|
||||
await this.updateSession(sessionId, { title: fallbackTitle })
|
||||
} catch {
|
||||
// Silently fail - session keeps "New Chat" title
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async deleteAllSessions() {
|
||||
try {
|
||||
await ChatSession.query().delete()
|
||||
|
|
|
|||
|
|
@ -23,9 +23,9 @@ import type {
|
|||
} from '../../types/collections.js'
|
||||
|
||||
const SPEC_URLS: Record<ManifestType, string> = {
|
||||
zim_categories: 'https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/master/collections/kiwix-categories.json',
|
||||
maps: 'https://github.com/Crosstalk-Solutions/project-nomad/raw/refs/heads/master/collections/maps.json',
|
||||
wikipedia: 'https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/master/collections/wikipedia.json',
|
||||
zim_categories: 'https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/collections/kiwix-categories.json',
|
||||
maps: 'https://github.com/Crosstalk-Solutions/project-nomad/raw/refs/heads/main/collections/maps.json',
|
||||
wikipedia: 'https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/collections/wikipedia.json',
|
||||
}
|
||||
|
||||
const VALIDATORS: Record<ManifestType, any> = {
|
||||
|
|
|
|||
484
admin/app/services/container_registry_service.ts
Normal file
484
admin/app/services/container_registry_service.ts
Normal file
|
|
@ -0,0 +1,484 @@
|
|||
import logger from '@adonisjs/core/services/logger'
|
||||
import { isNewerVersion, parseMajorVersion } from '../utils/version.js'
|
||||
|
||||
export interface ParsedImageReference {
|
||||
registry: string
|
||||
namespace: string
|
||||
repo: string
|
||||
tag: string
|
||||
/** Full name for registry API calls: namespace/repo */
|
||||
fullName: string
|
||||
}
|
||||
|
||||
export interface AvailableUpdate {
|
||||
tag: string
|
||||
isLatest: boolean
|
||||
releaseUrl?: string
|
||||
}
|
||||
|
||||
interface TokenCacheEntry {
|
||||
token: string
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
const SEMVER_TAG_PATTERN = /^v?(\d+\.\d+(?:\.\d+)?)$/
|
||||
const PLATFORM_SUFFIXES = ['-arm64', '-amd64', '-alpine', '-slim', '-cuda', '-rocm']
|
||||
const REJECTED_TAGS = new Set(['latest', 'nightly', 'edge', 'dev', 'beta', 'alpha', 'canary', 'rc', 'test', 'debug'])
|
||||
|
||||
export class ContainerRegistryService {
|
||||
private tokenCache = new Map<string, TokenCacheEntry>()
|
||||
private sourceUrlCache = new Map<string, string | null>()
|
||||
private releaseTagPrefixCache = new Map<string, string>()
|
||||
|
||||
/**
|
||||
* Parse a Docker image reference string into its components.
|
||||
*/
|
||||
parseImageReference(image: string): ParsedImageReference {
|
||||
let registry: string
|
||||
let remainder: string
|
||||
let tag = 'latest'
|
||||
|
||||
// Split off the tag
|
||||
const lastColon = image.lastIndexOf(':')
|
||||
if (lastColon > -1 && !image.substring(lastColon).includes('/')) {
|
||||
tag = image.substring(lastColon + 1)
|
||||
image = image.substring(0, lastColon)
|
||||
}
|
||||
|
||||
// Determine registry vs image path
|
||||
const parts = image.split('/')
|
||||
|
||||
if (parts.length === 1) {
|
||||
// e.g. "nginx" → Docker Hub library image
|
||||
registry = 'registry-1.docker.io'
|
||||
remainder = `library/${parts[0]}`
|
||||
} else if (parts.length === 2 && !parts[0].includes('.') && !parts[0].includes(':')) {
|
||||
// e.g. "ollama/ollama" → Docker Hub user image
|
||||
registry = 'registry-1.docker.io'
|
||||
remainder = image
|
||||
} else {
|
||||
// e.g. "ghcr.io/kiwix/kiwix-serve" → custom registry
|
||||
registry = parts[0]
|
||||
remainder = parts.slice(1).join('/')
|
||||
}
|
||||
|
||||
const namespaceParts = remainder.split('/')
|
||||
const repo = namespaceParts.pop()!
|
||||
const namespace = namespaceParts.join('/')
|
||||
|
||||
return {
|
||||
registry,
|
||||
namespace,
|
||||
repo,
|
||||
tag,
|
||||
fullName: remainder,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an anonymous auth token for the given registry and repository.
|
||||
* NOTE: This could be expanded in the future to support private repo authentication
|
||||
*/
|
||||
private async getToken(registry: string, fullName: string): Promise<string> {
|
||||
const cacheKey = `${registry}/${fullName}`
|
||||
const cached = this.tokenCache.get(cacheKey)
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached.token
|
||||
}
|
||||
|
||||
let tokenUrl: string
|
||||
if (registry === 'registry-1.docker.io') {
|
||||
tokenUrl = `https://auth.docker.io/token?service=registry.docker.io&scope=repository:${fullName}:pull`
|
||||
} else if (registry === 'ghcr.io') {
|
||||
tokenUrl = `https://ghcr.io/token?service=ghcr.io&scope=repository:${fullName}:pull`
|
||||
} else {
|
||||
// For other registries, try the standard v2 token endpoint
|
||||
tokenUrl = `https://${registry}/token?service=${registry}&scope=repository:${fullName}:pull`
|
||||
}
|
||||
|
||||
const response = await this.fetchWithRetry(tokenUrl)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get auth token from ${registry}: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { token?: string; access_token?: string }
|
||||
const token = data.token || data.access_token || ''
|
||||
|
||||
if (!token) {
|
||||
throw new Error(`No token returned from ${registry}`)
|
||||
}
|
||||
|
||||
// Cache for 5 minutes (tokens usually last longer, but be conservative)
|
||||
this.tokenCache.set(cacheKey, {
|
||||
token,
|
||||
expiresAt: Date.now() + 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
return token
|
||||
}
|
||||
|
||||
/**
|
||||
* List all tags for a given image from the registry.
|
||||
*/
|
||||
async listTags(parsed: ParsedImageReference): Promise<string[]> {
|
||||
const token = await this.getToken(parsed.registry, parsed.fullName)
|
||||
const allTags: string[] = []
|
||||
let url = `https://${parsed.registry}/v2/${parsed.fullName}/tags/list?n=1000`
|
||||
|
||||
while (url) {
|
||||
const response = await this.fetchWithRetry(url, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to list tags for ${parsed.fullName}: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { tags?: string[] }
|
||||
if (data.tags) {
|
||||
allTags.push(...data.tags)
|
||||
}
|
||||
|
||||
// Handle pagination via Link header
|
||||
const linkHeader = response.headers.get('link')
|
||||
if (linkHeader) {
|
||||
const match = linkHeader.match(/<([^>]+)>;\s*rel="next"/)
|
||||
url = match ? match[1] : ''
|
||||
} else {
|
||||
url = ''
|
||||
}
|
||||
}
|
||||
|
||||
return allTags
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific tag supports the given architecture by fetching its manifest.
|
||||
*/
|
||||
async checkArchSupport(parsed: ParsedImageReference, tag: string, hostArch: string): Promise<boolean> {
|
||||
try {
|
||||
const token = await this.getToken(parsed.registry, parsed.fullName)
|
||||
const url = `https://${parsed.registry}/v2/${parsed.fullName}/manifests/${tag}`
|
||||
|
||||
const response = await this.fetchWithRetry(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: [
|
||||
'application/vnd.oci.image.index.v1+json',
|
||||
'application/vnd.docker.distribution.manifest.list.v2+json',
|
||||
'application/vnd.oci.image.manifest.v1+json',
|
||||
'application/vnd.docker.distribution.manifest.v2+json',
|
||||
].join(', '),
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) return true // If we can't check, assume it's compatible
|
||||
|
||||
const manifest = (await response.json()) as {
|
||||
mediaType?: string
|
||||
manifests?: Array<{ platform?: { architecture?: string } }>
|
||||
}
|
||||
const mediaType = manifest.mediaType || response.headers.get('content-type') || ''
|
||||
|
||||
// Manifest list — check if any platform matches
|
||||
if (
|
||||
mediaType.includes('manifest.list') ||
|
||||
mediaType.includes('image.index') ||
|
||||
manifest.manifests
|
||||
) {
|
||||
const manifests = manifest.manifests || []
|
||||
return manifests.some(
|
||||
(m: any) => m.platform && m.platform.architecture === hostArch
|
||||
)
|
||||
}
|
||||
|
||||
// Single manifest — assume compatible (can't easily determine arch without fetching config blob)
|
||||
return true
|
||||
} catch (error) {
|
||||
logger.warn(`[ContainerRegistryService] Error checking arch for ${tag}: ${error.message}`)
|
||||
return true // Assume compatible on error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the source repository URL from an image's OCI labels.
|
||||
* Uses the standardized `org.opencontainers.image.source` label.
|
||||
* Result is cached per image (not per tag).
|
||||
*/
|
||||
async getSourceUrl(parsed: ParsedImageReference): Promise<string | null> {
|
||||
const cacheKey = `${parsed.registry}/${parsed.fullName}`
|
||||
if (this.sourceUrlCache.has(cacheKey)) {
|
||||
return this.sourceUrlCache.get(cacheKey)!
|
||||
}
|
||||
|
||||
try {
|
||||
const token = await this.getToken(parsed.registry, parsed.fullName)
|
||||
|
||||
// First get the manifest to find the config blob digest
|
||||
const manifestUrl = `https://${parsed.registry}/v2/${parsed.fullName}/manifests/${parsed.tag}`
|
||||
const manifestRes = await this.fetchWithRetry(manifestUrl, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: [
|
||||
'application/vnd.oci.image.manifest.v1+json',
|
||||
'application/vnd.docker.distribution.manifest.v2+json',
|
||||
'application/vnd.oci.image.index.v1+json',
|
||||
'application/vnd.docker.distribution.manifest.list.v2+json',
|
||||
].join(', '),
|
||||
},
|
||||
})
|
||||
|
||||
if (!manifestRes.ok) {
|
||||
this.sourceUrlCache.set(cacheKey, null)
|
||||
return null
|
||||
}
|
||||
|
||||
const manifest = (await manifestRes.json()) as {
|
||||
config?: { digest?: string }
|
||||
manifests?: Array<{ digest?: string; mediaType?: string; platform?: { architecture?: string } }>
|
||||
}
|
||||
|
||||
// If this is a manifest list, pick the first manifest to get the config
|
||||
let configDigest = manifest.config?.digest
|
||||
if (!configDigest && manifest.manifests?.length) {
|
||||
const firstManifest = manifest.manifests[0]
|
||||
if (firstManifest.digest) {
|
||||
const childRes = await this.fetchWithRetry(
|
||||
`https://${parsed.registry}/v2/${parsed.fullName}/manifests/${firstManifest.digest}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json',
|
||||
},
|
||||
}
|
||||
)
|
||||
if (childRes.ok) {
|
||||
const childManifest = (await childRes.json()) as { config?: { digest?: string } }
|
||||
configDigest = childManifest.config?.digest
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!configDigest) {
|
||||
this.sourceUrlCache.set(cacheKey, null)
|
||||
return null
|
||||
}
|
||||
|
||||
// Fetch the config blob to read labels
|
||||
const blobUrl = `https://${parsed.registry}/v2/${parsed.fullName}/blobs/${configDigest}`
|
||||
const blobRes = await this.fetchWithRetry(blobUrl, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
|
||||
if (!blobRes.ok) {
|
||||
this.sourceUrlCache.set(cacheKey, null)
|
||||
return null
|
||||
}
|
||||
|
||||
const config = (await blobRes.json()) as {
|
||||
config?: { Labels?: Record<string, string> }
|
||||
}
|
||||
|
||||
const sourceUrl = config.config?.Labels?.['org.opencontainers.image.source'] || null
|
||||
this.sourceUrlCache.set(cacheKey, sourceUrl)
|
||||
return sourceUrl
|
||||
} catch (error) {
|
||||
logger.warn(`[ContainerRegistryService] Failed to get source URL for ${cacheKey}: ${error.message}`)
|
||||
this.sourceUrlCache.set(cacheKey, null)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether a GitHub/GitLab repo uses a 'v' prefix on release tags.
|
||||
* Probes the GitHub API with the current tag to determine the convention,
|
||||
* then caches the result per source URL.
|
||||
*/
|
||||
async detectReleaseTagPrefix(sourceUrl: string, sampleTag: string): Promise<string> {
|
||||
if (this.releaseTagPrefixCache.has(sourceUrl)) {
|
||||
return this.releaseTagPrefixCache.get(sourceUrl)!
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(sourceUrl)
|
||||
if (url.hostname !== 'github.com') {
|
||||
this.releaseTagPrefixCache.set(sourceUrl, '')
|
||||
return ''
|
||||
}
|
||||
|
||||
const cleanPath = url.pathname.replace(/\.git$/, '').replace(/\/$/, '')
|
||||
const strippedTag = sampleTag.replace(/^v/, '')
|
||||
const vTag = `v${strippedTag}`
|
||||
|
||||
// Try both variants against GitHub's API — the one that 200s tells us the convention
|
||||
// Try v-prefixed first since it's more common
|
||||
const vRes = await this.fetchWithRetry(
|
||||
`https://api.github.com/repos${cleanPath}/releases/tags/${vTag}`,
|
||||
{ headers: { Accept: 'application/vnd.github.v3+json', 'User-Agent': 'ProjectNomad' } },
|
||||
1
|
||||
)
|
||||
if (vRes.ok) {
|
||||
this.releaseTagPrefixCache.set(sourceUrl, 'v')
|
||||
return 'v'
|
||||
}
|
||||
|
||||
const plainRes = await this.fetchWithRetry(
|
||||
`https://api.github.com/repos${cleanPath}/releases/tags/${strippedTag}`,
|
||||
{ headers: { Accept: 'application/vnd.github.v3+json', 'User-Agent': 'ProjectNomad' } },
|
||||
1
|
||||
)
|
||||
if (plainRes.ok) {
|
||||
this.releaseTagPrefixCache.set(sourceUrl, '')
|
||||
return ''
|
||||
}
|
||||
} catch {
|
||||
// On error, fall through to default
|
||||
}
|
||||
|
||||
// Default: no prefix modification
|
||||
this.releaseTagPrefixCache.set(sourceUrl, '')
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a release URL for a specific tag given a source repository URL and
|
||||
* the detected release tag prefix convention.
|
||||
* Supports GitHub and GitLab URL patterns.
|
||||
*/
|
||||
buildReleaseUrl(sourceUrl: string, tag: string, releaseTagPrefix: string): string | undefined {
|
||||
try {
|
||||
const url = new URL(sourceUrl)
|
||||
if (url.hostname === 'github.com' || url.hostname.includes('gitlab')) {
|
||||
const cleanPath = url.pathname.replace(/\.git$/, '').replace(/\/$/, '')
|
||||
const strippedTag = tag.replace(/^v/, '')
|
||||
const releaseTag = releaseTagPrefix ? `${releaseTagPrefix}${strippedTag}` : strippedTag
|
||||
return `${url.origin}${cleanPath}/releases/tag/${releaseTag}`
|
||||
}
|
||||
} catch {
|
||||
// Invalid URL, skip
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter and sort tags to find compatible updates for a service.
|
||||
*/
|
||||
filterCompatibleUpdates(
|
||||
tags: string[],
|
||||
currentTag: string,
|
||||
majorVersion: number
|
||||
): string[] {
|
||||
return tags
|
||||
.filter((tag) => {
|
||||
// Must match semver pattern
|
||||
if (!SEMVER_TAG_PATTERN.test(tag)) return false
|
||||
|
||||
// Reject known non-version tags
|
||||
if (REJECTED_TAGS.has(tag.toLowerCase())) return false
|
||||
|
||||
// Reject platform suffixes
|
||||
if (PLATFORM_SUFFIXES.some((suffix) => tag.toLowerCase().endsWith(suffix))) return false
|
||||
|
||||
// Must be same major version
|
||||
if (parseMajorVersion(tag) !== majorVersion) return false
|
||||
|
||||
// Must be newer than current
|
||||
return isNewerVersion(tag, currentTag)
|
||||
})
|
||||
.sort((a, b) => (isNewerVersion(a, b) ? -1 : 1)) // Newest first
|
||||
}
|
||||
|
||||
/**
|
||||
* High-level method to get available updates for a service.
|
||||
* Returns a sorted list of compatible newer versions (newest first).
|
||||
*/
|
||||
async getAvailableUpdates(
|
||||
containerImage: string,
|
||||
hostArch: string,
|
||||
fallbackSourceRepo?: string | null
|
||||
): Promise<AvailableUpdate[]> {
|
||||
const parsed = this.parseImageReference(containerImage)
|
||||
const currentTag = parsed.tag
|
||||
|
||||
if (currentTag === 'latest') {
|
||||
logger.warn(
|
||||
`[ContainerRegistryService] Cannot check updates for ${containerImage} — using :latest tag`
|
||||
)
|
||||
return []
|
||||
}
|
||||
|
||||
const majorVersion = parseMajorVersion(currentTag)
|
||||
|
||||
// Fetch tags and source URL in parallel
|
||||
const [tags, ociSourceUrl] = await Promise.all([
|
||||
this.listTags(parsed),
|
||||
this.getSourceUrl(parsed),
|
||||
])
|
||||
|
||||
// OCI label takes precedence, fall back to DB-stored source_repo
|
||||
const sourceUrl = ociSourceUrl || fallbackSourceRepo || null
|
||||
|
||||
const compatible = this.filterCompatibleUpdates(tags, currentTag, majorVersion)
|
||||
|
||||
// Detect release tag prefix convention (e.g. 'v' vs no prefix) if we have a source URL
|
||||
let releaseTagPrefix = ''
|
||||
if (sourceUrl) {
|
||||
releaseTagPrefix = await this.detectReleaseTagPrefix(sourceUrl, currentTag)
|
||||
}
|
||||
|
||||
// Check architecture support for the top candidates (limit checks to save API calls)
|
||||
const maxArchChecks = 10
|
||||
const results: AvailableUpdate[] = []
|
||||
|
||||
for (const tag of compatible.slice(0, maxArchChecks)) {
|
||||
const supported = await this.checkArchSupport(parsed, tag, hostArch)
|
||||
if (supported) {
|
||||
results.push({
|
||||
tag,
|
||||
isLatest: results.length === 0,
|
||||
releaseUrl: sourceUrl ? this.buildReleaseUrl(sourceUrl, tag, releaseTagPrefix) : undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// For remaining tags (beyond arch check limit), include them but mark as not latest
|
||||
for (const tag of compatible.slice(maxArchChecks)) {
|
||||
results.push({
|
||||
tag,
|
||||
isLatest: false,
|
||||
releaseUrl: sourceUrl ? this.buildReleaseUrl(sourceUrl, tag, releaseTagPrefix) : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch with retry and exponential backoff for rate limiting.
|
||||
*/
|
||||
private async fetchWithRetry(
|
||||
url: string,
|
||||
init?: RequestInit,
|
||||
maxRetries = 3
|
||||
): Promise<Response> {
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
const response = await fetch(url, init)
|
||||
|
||||
if (response.status === 429 && attempt < maxRetries) {
|
||||
const retryAfter = response.headers.get('retry-after')
|
||||
const delay = retryAfter
|
||||
? parseInt(retryAfter, 10) * 1000
|
||||
: Math.pow(2, attempt) * 1000
|
||||
logger.warn(
|
||||
`[ContainerRegistryService] Rate limited on ${url}, retrying in ${delay}ms`
|
||||
)
|
||||
await new Promise((resolve) => setTimeout(resolve, delay))
|
||||
continue
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
throw new Error(`Failed to fetch ${url} after ${maxRetries} retries`)
|
||||
}
|
||||
}
|
||||
|
|
@ -113,8 +113,8 @@ export class DockerService {
|
|||
const containers = await this.docker.listContainers({ all: true })
|
||||
const containerMap = new Map<string, Docker.ContainerInfo>()
|
||||
containers.forEach((container) => {
|
||||
const name = container.Names[0].replace('/', '')
|
||||
if (name.startsWith('nomad_')) {
|
||||
const name = container.Names[0]?.replace('/', '')
|
||||
if (name && name.startsWith('nomad_')) {
|
||||
containerMap.set(name, container)
|
||||
}
|
||||
})
|
||||
|
|
@ -546,7 +546,7 @@ export class DockerService {
|
|||
// If Ollama was just installed, trigger Nomad docs discovery and embedding
|
||||
if (service.service_name === SERVICE_NAMES.OLLAMA) {
|
||||
logger.info('[DockerService] Ollama installation complete. Default behavior is to not enable chat suggestions.')
|
||||
await KVStore.setValue('chat.suggestionsEnabled', "false")
|
||||
await KVStore.setValue('chat.suggestionsEnabled', false)
|
||||
|
||||
logger.info('[DockerService] Ollama installation complete. Triggering Nomad docs discovery...')
|
||||
|
||||
|
|
@ -615,8 +615,8 @@ export class DockerService {
|
|||
* We'll download the lightweight mini Wikipedia Top 100 zim file for this purpose.
|
||||
**/
|
||||
const WIKIPEDIA_ZIM_URL =
|
||||
'https://github.com/Crosstalk-Solutions/project-nomad/raw/refs/heads/master/install/wikipedia_en_100_mini_2025-06.zim'
|
||||
const filename = 'wikipedia_en_100_mini_2025-06.zim'
|
||||
'https://github.com/Crosstalk-Solutions/project-nomad/raw/refs/heads/main/install/wikipedia_en_100_mini_2026-01.zim'
|
||||
const filename = 'wikipedia_en_100_mini_2026-01.zim'
|
||||
const filepath = join(process.cwd(), ZIM_STORAGE_PATH, filename)
|
||||
logger.info(`[DockerService] Kiwix Serve pre-install: Downloading ZIM file to ${filepath}`)
|
||||
|
||||
|
|
@ -691,6 +691,7 @@ export class DockerService {
|
|||
const runtimes = dockerInfo.Runtimes || {}
|
||||
if ('nvidia' in runtimes) {
|
||||
logger.info('[DockerService] NVIDIA container runtime detected via Docker API')
|
||||
await this._persistGPUType('nvidia')
|
||||
return { type: 'nvidia' }
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -722,12 +723,26 @@ export class DockerService {
|
|||
)
|
||||
if (amdCheck.trim()) {
|
||||
logger.info('[DockerService] AMD GPU detected via lspci')
|
||||
await this._persistGPUType('amd')
|
||||
return { type: 'amd' }
|
||||
}
|
||||
} catch (error) {
|
||||
// lspci not available, continue
|
||||
}
|
||||
|
||||
// Last resort: check if we previously detected a GPU and it's likely still present.
|
||||
// This handles cases where live detection fails transiently (e.g., Docker daemon
|
||||
// hiccup, runtime temporarily unavailable) but the hardware hasn't changed.
|
||||
try {
|
||||
const savedType = await KVStore.getValue('gpu.type')
|
||||
if (savedType === 'nvidia' || savedType === 'amd') {
|
||||
logger.info(`[DockerService] No GPU detected live, but KV store has '${savedType}' from previous detection. Using saved value.`)
|
||||
return { type: savedType as 'nvidia' | 'amd' }
|
||||
}
|
||||
} catch {
|
||||
// KV store not available, continue
|
||||
}
|
||||
|
||||
logger.info('[DockerService] No GPU detected')
|
||||
return { type: 'none' }
|
||||
} catch (error) {
|
||||
|
|
@ -736,6 +751,15 @@ export class DockerService {
|
|||
}
|
||||
}
|
||||
|
||||
private async _persistGPUType(type: 'nvidia' | 'amd'): Promise<void> {
|
||||
try {
|
||||
await KVStore.setValue('gpu.type', type)
|
||||
logger.info(`[DockerService] Persisted GPU type '${type}' to KV store`)
|
||||
} catch (error) {
|
||||
logger.warn(`[DockerService] Failed to persist GPU type: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover AMD GPU DRI devices dynamically.
|
||||
* Returns an array of device configurations for Docker.
|
||||
|
|
@ -792,6 +816,225 @@ export class DockerService {
|
|||
// }
|
||||
// }
|
||||
|
||||
/**
|
||||
* Update a service container to a new image version while preserving volumes and data.
|
||||
* Includes automatic rollback if the new container fails health checks.
|
||||
*/
|
||||
async updateContainer(
|
||||
serviceName: string,
|
||||
targetVersion: string
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const service = await Service.query().where('service_name', serviceName).first()
|
||||
if (!service) {
|
||||
return { success: false, message: `Service ${serviceName} not found` }
|
||||
}
|
||||
if (!service.installed) {
|
||||
return { success: false, message: `Service ${serviceName} is not installed` }
|
||||
}
|
||||
if (this.activeInstallations.has(serviceName)) {
|
||||
return { success: false, message: `Service ${serviceName} already has an operation in progress` }
|
||||
}
|
||||
|
||||
this.activeInstallations.add(serviceName)
|
||||
|
||||
// Compute new image string
|
||||
const currentImage = service.container_image
|
||||
const imageBase = currentImage.includes(':')
|
||||
? currentImage.substring(0, currentImage.lastIndexOf(':'))
|
||||
: currentImage
|
||||
const newImage = `${imageBase}:${targetVersion}`
|
||||
|
||||
// Step 1: Pull new image
|
||||
this._broadcast(serviceName, 'update-pulling', `Pulling image ${newImage}...`)
|
||||
const pullStream = await this.docker.pull(newImage)
|
||||
await new Promise((res) => this.docker.modem.followProgress(pullStream, res))
|
||||
|
||||
// Step 2: Find and stop existing container
|
||||
this._broadcast(serviceName, 'update-stopping', `Stopping current container...`)
|
||||
const containers = await this.docker.listContainers({ all: true })
|
||||
const existingContainer = containers.find((c) => c.Names.includes(`/${serviceName}`))
|
||||
|
||||
if (!existingContainer) {
|
||||
this.activeInstallations.delete(serviceName)
|
||||
return { success: false, message: `Container for ${serviceName} not found` }
|
||||
}
|
||||
|
||||
const oldContainer = this.docker.getContainer(existingContainer.Id)
|
||||
|
||||
// Inspect to capture full config before stopping
|
||||
const inspectData = await oldContainer.inspect()
|
||||
|
||||
if (existingContainer.State === 'running') {
|
||||
await oldContainer.stop({ t: 15 })
|
||||
}
|
||||
|
||||
// Step 3: Rename old container as safety net
|
||||
const oldName = `${serviceName}_old`
|
||||
await oldContainer.rename({ name: oldName })
|
||||
|
||||
// Step 4: Create new container with inspected config + new image
|
||||
this._broadcast(serviceName, 'update-creating', `Creating updated container...`)
|
||||
|
||||
const hostConfig = inspectData.HostConfig || {}
|
||||
|
||||
// Re-run GPU detection for Ollama so updates always reflect the current GPU environment.
|
||||
// This handles cases where the NVIDIA Container Toolkit was installed after the initial
|
||||
// Ollama setup, and ensures DeviceRequests are always built fresh rather than relying on
|
||||
// round-tripping the Docker inspect format back into the create API.
|
||||
let updatedDeviceRequests: any[] | undefined = undefined
|
||||
if (serviceName === SERVICE_NAMES.OLLAMA) {
|
||||
const gpuResult = await this._detectGPUType()
|
||||
|
||||
if (gpuResult.type === 'nvidia') {
|
||||
this._broadcast(
|
||||
serviceName,
|
||||
'update-gpu-config',
|
||||
`NVIDIA container runtime detected. Configuring updated container with GPU support...`
|
||||
)
|
||||
updatedDeviceRequests = [
|
||||
{
|
||||
Driver: 'nvidia',
|
||||
Count: -1,
|
||||
Capabilities: [['gpu']],
|
||||
},
|
||||
]
|
||||
} else if (gpuResult.type === 'amd') {
|
||||
this._broadcast(
|
||||
serviceName,
|
||||
'update-gpu-config',
|
||||
`AMD GPU detected. ROCm GPU acceleration is not yet supported — using CPU-only configuration.`
|
||||
)
|
||||
} else if (gpuResult.toolkitMissing) {
|
||||
this._broadcast(
|
||||
serviceName,
|
||||
'update-gpu-config',
|
||||
`NVIDIA GPU detected but NVIDIA Container Toolkit is not installed. Using CPU-only configuration. Install the toolkit and reinstall AI Assistant for GPU acceleration: https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html`
|
||||
)
|
||||
} else {
|
||||
this._broadcast(serviceName, 'update-gpu-config', `No GPU detected. Using CPU-only configuration.`)
|
||||
}
|
||||
}
|
||||
|
||||
const newContainerConfig: any = {
|
||||
Image: newImage,
|
||||
name: serviceName,
|
||||
Env: inspectData.Config?.Env || undefined,
|
||||
Cmd: inspectData.Config?.Cmd || undefined,
|
||||
ExposedPorts: inspectData.Config?.ExposedPorts || undefined,
|
||||
WorkingDir: inspectData.Config?.WorkingDir || undefined,
|
||||
User: inspectData.Config?.User || undefined,
|
||||
HostConfig: {
|
||||
Binds: hostConfig.Binds || undefined,
|
||||
PortBindings: hostConfig.PortBindings || undefined,
|
||||
RestartPolicy: hostConfig.RestartPolicy || undefined,
|
||||
DeviceRequests: serviceName === SERVICE_NAMES.OLLAMA ? updatedDeviceRequests : (hostConfig.DeviceRequests || undefined),
|
||||
Devices: hostConfig.Devices || undefined,
|
||||
},
|
||||
NetworkingConfig: inspectData.NetworkSettings?.Networks
|
||||
? {
|
||||
EndpointsConfig: Object.fromEntries(
|
||||
Object.keys(inspectData.NetworkSettings.Networks).map((net) => [net, {}])
|
||||
),
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
|
||||
// Remove undefined values from HostConfig
|
||||
Object.keys(newContainerConfig.HostConfig).forEach((key) => {
|
||||
if (newContainerConfig.HostConfig[key] === undefined) {
|
||||
delete newContainerConfig.HostConfig[key]
|
||||
}
|
||||
})
|
||||
|
||||
let newContainer: any
|
||||
try {
|
||||
newContainer = await this.docker.createContainer(newContainerConfig)
|
||||
} catch (createError) {
|
||||
// Rollback: rename old container back
|
||||
this._broadcast(serviceName, 'update-rollback', `Failed to create new container: ${createError.message}. Rolling back...`)
|
||||
const rollbackContainer = this.docker.getContainer((await this.docker.listContainers({ all: true })).find((c) => c.Names.includes(`/${oldName}`))!.Id)
|
||||
await rollbackContainer.rename({ name: serviceName })
|
||||
await rollbackContainer.start()
|
||||
this.activeInstallations.delete(serviceName)
|
||||
return { success: false, message: `Failed to create updated container: ${createError.message}` }
|
||||
}
|
||||
|
||||
// Step 5: Start new container
|
||||
this._broadcast(serviceName, 'update-starting', `Starting updated container...`)
|
||||
await newContainer.start()
|
||||
|
||||
// Step 6: Health check — verify container stays running for 5 seconds
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000))
|
||||
const newContainerInfo = await newContainer.inspect()
|
||||
|
||||
if (newContainerInfo.State?.Running) {
|
||||
// Healthy — clean up old container
|
||||
try {
|
||||
const oldContainerRef = this.docker.getContainer(
|
||||
(await this.docker.listContainers({ all: true })).find((c) =>
|
||||
c.Names.includes(`/${oldName}`)
|
||||
)?.Id || ''
|
||||
)
|
||||
await oldContainerRef.remove({ force: true })
|
||||
} catch {
|
||||
// Old container may already be gone
|
||||
}
|
||||
|
||||
// Update DB
|
||||
service.container_image = newImage
|
||||
service.available_update_version = null
|
||||
await service.save()
|
||||
|
||||
this.activeInstallations.delete(serviceName)
|
||||
this._broadcast(
|
||||
serviceName,
|
||||
'update-complete',
|
||||
`Successfully updated ${serviceName} to ${targetVersion}`
|
||||
)
|
||||
return { success: true, message: `Service ${serviceName} updated to ${targetVersion}` }
|
||||
} else {
|
||||
// Unhealthy — rollback
|
||||
this._broadcast(
|
||||
serviceName,
|
||||
'update-rollback',
|
||||
`New container failed health check. Rolling back to previous version...`
|
||||
)
|
||||
|
||||
try {
|
||||
await newContainer.stop({ t: 5 }).catch(() => {})
|
||||
await newContainer.remove({ force: true })
|
||||
} catch {
|
||||
// Best effort cleanup
|
||||
}
|
||||
|
||||
// Restore old container
|
||||
const oldContainers = await this.docker.listContainers({ all: true })
|
||||
const oldRef = oldContainers.find((c) => c.Names.includes(`/${oldName}`))
|
||||
if (oldRef) {
|
||||
const rollbackContainer = this.docker.getContainer(oldRef.Id)
|
||||
await rollbackContainer.rename({ name: serviceName })
|
||||
await rollbackContainer.start()
|
||||
}
|
||||
|
||||
this.activeInstallations.delete(serviceName)
|
||||
return {
|
||||
success: false,
|
||||
message: `Update failed: new container did not stay running. Rolled back to previous version.`,
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.activeInstallations.delete(serviceName)
|
||||
this._broadcast(
|
||||
serviceName,
|
||||
'update-rollback',
|
||||
`Update failed: ${error.message}`
|
||||
)
|
||||
logger.error(`[DockerService] Update failed for ${serviceName}: ${error.message}`)
|
||||
return { success: false, message: `Update failed: ${error.message}` }
|
||||
}
|
||||
}
|
||||
|
||||
private _broadcast(service: string, status: string, message: string) {
|
||||
transmit.broadcast(BROADCAST_CHANNELS.SERVICE_INSTALLATION, {
|
||||
service_name: service,
|
||||
|
|
|
|||
|
|
@ -66,12 +66,19 @@ export class DocsService {
|
|||
|
||||
const filename = _filename.endsWith('.md') ? _filename : `${_filename}.md`
|
||||
|
||||
const fileExists = await getFileStatsIfExists(path.join(this.docsPath, filename))
|
||||
// Prevent path traversal — resolved path must stay within the docs directory
|
||||
const basePath = path.resolve(this.docsPath)
|
||||
const fullPath = path.resolve(path.join(this.docsPath, filename))
|
||||
if (!fullPath.startsWith(basePath + path.sep)) {
|
||||
throw new Error('Invalid document slug')
|
||||
}
|
||||
|
||||
const fileExists = await getFileStatsIfExists(fullPath)
|
||||
if (!fileExists) {
|
||||
throw new Error(`File not found: ${filename}`)
|
||||
}
|
||||
|
||||
const fileStream = await getFile(path.join(this.docsPath, filename), 'stream')
|
||||
const fileStream = await getFile(fullPath, 'stream')
|
||||
if (!fileStream) {
|
||||
throw new Error(`Failed to read file stream: ${filename}`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ export class DownloadService {
|
|||
async listDownloadJobs(filetype?: string): Promise<DownloadJobWithProgress[]> {
|
||||
// Get regular file download jobs (zim, map, etc.)
|
||||
const queue = this.queueService.getQueue(RunDownloadJob.queue)
|
||||
const fileJobs = await queue.getJobs(['waiting', 'active', 'delayed'])
|
||||
const fileJobs = await queue.getJobs(['waiting', 'active', 'delayed', 'failed'])
|
||||
|
||||
const fileDownloads = fileJobs.map((job) => ({
|
||||
jobId: job.id!.toString(),
|
||||
|
|
@ -20,11 +20,13 @@ export class DownloadService {
|
|||
progress: parseInt(job.progress.toString(), 10),
|
||||
filepath: normalize(job.data.filepath),
|
||||
filetype: job.data.filetype,
|
||||
status: (job.failedReason ? 'failed' : 'active') as 'active' | 'failed',
|
||||
failedReason: job.failedReason || undefined,
|
||||
}))
|
||||
|
||||
// Get Ollama model download jobs
|
||||
const modelQueue = this.queueService.getQueue(DownloadModelJob.queue)
|
||||
const modelJobs = await modelQueue.getJobs(['waiting', 'active', 'delayed'])
|
||||
const modelJobs = await modelQueue.getJobs(['waiting', 'active', 'delayed', 'failed'])
|
||||
|
||||
const modelDownloads = modelJobs.map((job) => ({
|
||||
jobId: job.id!.toString(),
|
||||
|
|
@ -32,6 +34,8 @@ export class DownloadService {
|
|||
progress: parseInt(job.progress.toString(), 10),
|
||||
filepath: job.data.modelName || 'Unknown Model', // Use model name as filepath
|
||||
filetype: 'model',
|
||||
status: (job.failedReason ? 'failed' : 'active') as 'active' | 'failed',
|
||||
failedReason: job.failedReason || undefined,
|
||||
}))
|
||||
|
||||
const allDownloads = [...fileDownloads, ...modelDownloads]
|
||||
|
|
@ -39,7 +43,22 @@ export class DownloadService {
|
|||
// Filter by filetype if specified
|
||||
const filtered = allDownloads.filter((job) => !filetype || job.filetype === filetype)
|
||||
|
||||
// Sort so actively downloading items (progress > 0) appear first, then by progress descending
|
||||
return filtered.sort((a, b) => b.progress - a.progress)
|
||||
// Sort: active downloads first (by progress desc), then failed at the bottom
|
||||
return filtered.sort((a, b) => {
|
||||
if (a.status === 'failed' && b.status !== 'failed') return 1
|
||||
if (a.status !== 'failed' && b.status === 'failed') return -1
|
||||
return b.progress - a.progress
|
||||
})
|
||||
}
|
||||
|
||||
async removeFailedJob(jobId: string): Promise<void> {
|
||||
for (const queueName of [RunDownloadJob.queue, DownloadModelJob.queue]) {
|
||||
const queue = this.queueService.getQueue(queueName)
|
||||
const job = await queue.getJob(jobId)
|
||||
if (job) {
|
||||
await job.remove()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import {
|
|||
getFile,
|
||||
ensureDirectoryExists,
|
||||
} from '../utils/fs.js'
|
||||
import { join } from 'path'
|
||||
import { join, resolve, sep } from 'path'
|
||||
import urlJoin from 'url-join'
|
||||
import { RunDownloadJob } from '#jobs/run_download_job'
|
||||
import logger from '@adonisjs/core/services/logger'
|
||||
|
|
@ -260,7 +260,7 @@ export class MapService implements IMapService {
|
|||
}
|
||||
}
|
||||
|
||||
async generateStylesJSON(host: string | null = null): Promise<BaseStylesFile> {
|
||||
async generateStylesJSON(host: string | null = null, protocol: string = 'http'): Promise<BaseStylesFile> {
|
||||
if (!(await this.checkBaseAssetsExist())) {
|
||||
throw new Error('Base map assets are missing from storage/maps')
|
||||
}
|
||||
|
|
@ -281,8 +281,8 @@ export class MapService implements IMapService {
|
|||
* e.g. user is accessing from "example.com", but we would by default generate "localhost:8080/..." so maps would
|
||||
* fail to load.
|
||||
*/
|
||||
const sources = this.generateSourcesArray(host, regions)
|
||||
const baseUrl = this.getPublicFileBaseUrl(host, this.basemapsAssetsDir)
|
||||
const sources = this.generateSourcesArray(host, regions, protocol)
|
||||
const baseUrl = this.getPublicFileBaseUrl(host, this.basemapsAssetsDir, protocol)
|
||||
|
||||
const styles = await this.generateStylesFile(
|
||||
rawStyles,
|
||||
|
|
@ -342,9 +342,9 @@ export class MapService implements IMapService {
|
|||
return await listDirectoryContentsRecursive(this.baseDirPath)
|
||||
}
|
||||
|
||||
private generateSourcesArray(host: string | null, regions: FileEntry[]): BaseStylesFile['sources'][] {
|
||||
private generateSourcesArray(host: string | null, regions: FileEntry[], protocol: string = 'http'): BaseStylesFile['sources'][] {
|
||||
const sources: BaseStylesFile['sources'][] = []
|
||||
const baseUrl = this.getPublicFileBaseUrl(host, 'pmtiles')
|
||||
const baseUrl = this.getPublicFileBaseUrl(host, 'pmtiles', protocol)
|
||||
|
||||
for (const region of regions) {
|
||||
if (region.type === 'file' && region.name.endsWith('.pmtiles')) {
|
||||
|
|
@ -404,7 +404,13 @@ export class MapService implements IMapService {
|
|||
fileName += '.pmtiles'
|
||||
}
|
||||
|
||||
const fullPath = join(this.baseDirPath, 'pmtiles', fileName)
|
||||
const basePath = resolve(join(this.baseDirPath, 'pmtiles'))
|
||||
const fullPath = resolve(join(basePath, fileName))
|
||||
|
||||
// Prevent path traversal — resolved path must stay within the storage directory
|
||||
if (!fullPath.startsWith(basePath + sep)) {
|
||||
throw new Error('Invalid filename')
|
||||
}
|
||||
|
||||
const exists = await getFileStatsIfExists(fullPath)
|
||||
if (!exists) {
|
||||
|
|
@ -427,7 +433,7 @@ export class MapService implements IMapService {
|
|||
/*
|
||||
* Gets the appropriate public URL for a map asset depending on environment
|
||||
*/
|
||||
private getPublicFileBaseUrl(specifiedHost: string | null, childPath: string): string {
|
||||
private getPublicFileBaseUrl(specifiedHost: string | null, childPath: string, protocol: string = 'http'): string {
|
||||
function getHost() {
|
||||
try {
|
||||
const localUrlRaw = env.get('URL')
|
||||
|
|
@ -441,7 +447,7 @@ export class MapService implements IMapService {
|
|||
}
|
||||
|
||||
const host = specifiedHost || getHost()
|
||||
const withProtocol = host.startsWith('http') ? host : `http://${host}`
|
||||
const withProtocol = host.startsWith('http') ? host : `${protocol}://${host}`
|
||||
const baseUrlPath =
|
||||
process.env.NODE_ENV === 'production' ? childPath : urlJoin(this.mapStoragePath, childPath)
|
||||
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ export class OllamaService {
|
|||
* @param model Model name to download
|
||||
* @returns Success status and message
|
||||
*/
|
||||
async downloadModel(model: string, progressCallback?: (percent: number) => void): Promise<{ success: boolean; message: string }> {
|
||||
async downloadModel(model: string, progressCallback?: (percent: number) => void): Promise<{ success: boolean; message: string; retryable?: boolean }> {
|
||||
try {
|
||||
await this._ensureDependencies()
|
||||
if (!this.ollama) {
|
||||
|
|
@ -86,11 +86,21 @@ export class OllamaService {
|
|||
logger.info(`[OllamaService] Model "${model}" downloaded successfully.`)
|
||||
return { success: true, message: 'Model downloaded successfully.' }
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
logger.error(
|
||||
`[OllamaService] Failed to download model "${model}": ${error instanceof Error ? error.message : error
|
||||
}`
|
||||
`[OllamaService] Failed to download model "${model}": ${errorMessage}`
|
||||
)
|
||||
return { success: false, message: 'Failed to download model.' }
|
||||
|
||||
// Check for version mismatch (Ollama 412 response)
|
||||
const isVersionMismatch = errorMessage.includes('newer version of Ollama')
|
||||
const userMessage = isVersionMismatch
|
||||
? 'This model requires a newer version of Ollama. Please update AI Assistant from the Apps page.'
|
||||
: `Failed to download model: ${errorMessage}`
|
||||
|
||||
// Broadcast failure to connected clients so UI can show the error
|
||||
this.broadcastDownloadError(model, userMessage)
|
||||
|
||||
return { success: false, message: userMessage, retryable: !isVersionMismatch }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -183,25 +193,32 @@ export class OllamaService {
|
|||
}
|
||||
|
||||
async getAvailableModels(
|
||||
{ sort, recommendedOnly, query }: { sort?: 'pulls' | 'name'; recommendedOnly?: boolean, query: string | null } = {
|
||||
{ sort, recommendedOnly, query, limit, force }: { sort?: 'pulls' | 'name'; recommendedOnly?: boolean, query: string | null, limit?: number, force?: boolean } = {
|
||||
sort: 'pulls',
|
||||
recommendedOnly: false,
|
||||
query: null,
|
||||
limit: 15,
|
||||
}
|
||||
): Promise<NomadOllamaModel[] | null> {
|
||||
): Promise<{ models: NomadOllamaModel[], hasMore: boolean } | null> {
|
||||
try {
|
||||
const models = await this.retrieveAndRefreshModels(sort)
|
||||
const models = await this.retrieveAndRefreshModels(sort, force)
|
||||
if (!models) {
|
||||
// If we fail to get models from the API, return the fallback recommended models
|
||||
logger.warn(
|
||||
'[OllamaService] Returning fallback recommended models due to failure in fetching available models'
|
||||
)
|
||||
return FALLBACK_RECOMMENDED_OLLAMA_MODELS
|
||||
return {
|
||||
models: FALLBACK_RECOMMENDED_OLLAMA_MODELS,
|
||||
hasMore: false
|
||||
}
|
||||
}
|
||||
|
||||
if (!recommendedOnly) {
|
||||
const filteredModels = query ? this.fuseSearchModels(models, query) : models
|
||||
return filteredModels
|
||||
return {
|
||||
models: filteredModels.slice(0, limit || 15),
|
||||
hasMore: filteredModels.length > (limit || 15)
|
||||
}
|
||||
}
|
||||
|
||||
// If recommendedOnly is true, only return the first three models (if sorted by pulls, these will be the top 3)
|
||||
|
|
@ -217,10 +234,17 @@ export class OllamaService {
|
|||
})
|
||||
|
||||
if (query) {
|
||||
return this.fuseSearchModels(recommendedModels, query)
|
||||
const filteredRecommendedModels = this.fuseSearchModels(recommendedModels, query)
|
||||
return {
|
||||
models: filteredRecommendedModels,
|
||||
hasMore: filteredRecommendedModels.length > (limit || 15)
|
||||
}
|
||||
}
|
||||
|
||||
return recommendedModels
|
||||
return {
|
||||
models: recommendedModels,
|
||||
hasMore: recommendedModels.length > (limit || 15)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[OllamaService] Failed to get available models: ${error instanceof Error ? error.message : error}`
|
||||
|
|
@ -230,13 +254,18 @@ export class OllamaService {
|
|||
}
|
||||
|
||||
private async retrieveAndRefreshModels(
|
||||
sort?: 'pulls' | 'name'
|
||||
sort?: 'pulls' | 'name',
|
||||
force?: boolean
|
||||
): Promise<NomadOllamaModel[] | null> {
|
||||
try {
|
||||
const cachedModels = await this.readModelsFromCache()
|
||||
if (cachedModels) {
|
||||
logger.info('[OllamaService] Using cached available models data')
|
||||
return this.sortModels(cachedModels, sort)
|
||||
if (!force) {
|
||||
const cachedModels = await this.readModelsFromCache()
|
||||
if (cachedModels) {
|
||||
logger.info('[OllamaService] Using cached available models data')
|
||||
return this.sortModels(cachedModels, sort)
|
||||
}
|
||||
} else {
|
||||
logger.info('[OllamaService] Force refresh requested, bypassing cache')
|
||||
}
|
||||
|
||||
logger.info('[OllamaService] Fetching fresh available models from API')
|
||||
|
|
@ -253,7 +282,7 @@ export class OllamaService {
|
|||
}
|
||||
|
||||
const rawModels = response.data.models as NomadOllamaModel[]
|
||||
|
||||
|
||||
// Filter out tags where cloud is truthy, then remove models with no remaining tags
|
||||
const noCloud = rawModels
|
||||
.map((model) => ({
|
||||
|
|
@ -360,6 +389,15 @@ export class OllamaService {
|
|||
return models
|
||||
}
|
||||
|
||||
private broadcastDownloadError(model: string, error: string) {
|
||||
transmit.broadcast(BROADCAST_CHANNELS.OLLAMA_MODEL_DOWNLOAD, {
|
||||
model,
|
||||
percent: -1,
|
||||
error,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
private broadcastDownloadProgress(model: string, percent: number) {
|
||||
transmit.broadcast(BROADCAST_CHANNELS.OLLAMA_MODEL_DOWNLOAD, {
|
||||
model,
|
||||
|
|
|
|||
|
|
@ -12,16 +12,17 @@ import { OllamaService } from './ollama_service.js'
|
|||
import { SERVICE_NAMES } from '../../constants/service_names.js'
|
||||
import { removeStopwords } from 'stopword'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { join } from 'node:path'
|
||||
import { join, resolve, sep } from 'node:path'
|
||||
import KVStore from '#models/kv_store'
|
||||
import { parseBoolean } from '../utils/misc.js'
|
||||
import { ZIMExtractionService } from './zim_extraction_service.js'
|
||||
import { ZIM_BATCH_SIZE } from '../../constants/zim_extraction.js'
|
||||
import { ProcessAndEmbedFileResponse, ProcessZIMFileResponse, RAGResult, RerankedRAGResult } from '../../types/rag.js'
|
||||
|
||||
@inject()
|
||||
export class RagService {
|
||||
private qdrant: QdrantClient | null = null
|
||||
private qdrantInitPromise: Promise<void> | null = null
|
||||
private embeddingModelVerified = false
|
||||
public static UPLOADS_STORAGE_PATH = 'storage/kb_uploads'
|
||||
public static CONTENT_COLLECTION_NAME = 'nomad_knowledge_base'
|
||||
public static EMBEDDING_MODEL = 'nomic-embed-text:v1.5'
|
||||
|
|
@ -34,6 +35,7 @@ export class RagService {
|
|||
// Nomic Embed Text v1.5 uses task-specific prefixes for optimal performance
|
||||
public static SEARCH_DOCUMENT_PREFIX = 'search_document: '
|
||||
public static SEARCH_QUERY_PREFIX = 'search_query: '
|
||||
public static EMBEDDING_BATCH_SIZE = 8 // Conservative batch size for low-end hardware
|
||||
|
||||
constructor(
|
||||
private dockerService: DockerService,
|
||||
|
|
@ -76,6 +78,16 @@ export class RagService {
|
|||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Create payload indexes for faster filtering (idempotent — Qdrant ignores duplicates)
|
||||
await this.qdrant!.createPayloadIndex(collectionName, {
|
||||
field_name: 'source',
|
||||
field_schema: 'keyword',
|
||||
})
|
||||
await this.qdrant!.createPayloadIndex(collectionName, {
|
||||
field_name: 'content_type',
|
||||
field_schema: 'keyword',
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error ensuring Qdrant collection:', error)
|
||||
throw error
|
||||
|
|
@ -149,14 +161,57 @@ export class RagService {
|
|||
/**
|
||||
* Preprocesses a query to improve retrieval by expanding it with context.
|
||||
* This helps match documents even when using different terminology.
|
||||
* TODO: We could probably move this to a separate QueryPreprocessor class if it grows more complex, but for now it's manageable here.
|
||||
*/
|
||||
private static QUERY_EXPANSION_DICTIONARY: Record<string, string> = {
|
||||
'bob': 'bug out bag',
|
||||
'bov': 'bug out vehicle',
|
||||
'bol': 'bug out location',
|
||||
'edc': 'every day carry',
|
||||
'mre': 'meal ready to eat',
|
||||
'shtf': 'shit hits the fan',
|
||||
'teotwawki': 'the end of the world as we know it',
|
||||
'opsec': 'operational security',
|
||||
'ifak': 'individual first aid kit',
|
||||
'ghb': 'get home bag',
|
||||
'ghi': 'get home in',
|
||||
'wrol': 'without rule of law',
|
||||
'emp': 'electromagnetic pulse',
|
||||
'ham': 'ham amateur radio',
|
||||
'nbr': 'nuclear biological radiological',
|
||||
'cbrn': 'chemical biological radiological nuclear',
|
||||
'sar': 'search and rescue',
|
||||
'comms': 'communications radio',
|
||||
'fifo': 'first in first out',
|
||||
'mylar': 'mylar bag food storage',
|
||||
'paracord': 'paracord 550 cord',
|
||||
'ferro': 'ferro rod fire starter',
|
||||
'bivvy': 'bivvy bivy emergency shelter',
|
||||
'bdu': 'battle dress uniform',
|
||||
'gmrs': 'general mobile radio service',
|
||||
'frs': 'family radio service',
|
||||
'nbc': 'nuclear biological chemical',
|
||||
}
|
||||
|
||||
private preprocessQuery(query: string): string {
|
||||
// Future: this is a placeholder for more advanced query expansion techniques.
|
||||
// For now, we simply trim whitespace. Improvements could include:
|
||||
// - Synonym expansion using a thesaurus
|
||||
// - Adding related terms based on domain knowledge
|
||||
// - Using a language model to rephrase or elaborate the query
|
||||
const expanded = query.trim()
|
||||
let expanded = query.trim()
|
||||
|
||||
// Expand known domain abbreviations/acronyms
|
||||
const words = expanded.toLowerCase().split(/\s+/)
|
||||
const expansions: string[] = []
|
||||
|
||||
for (const word of words) {
|
||||
const cleaned = word.replace(/[^\w]/g, '')
|
||||
if (RagService.QUERY_EXPANSION_DICTIONARY[cleaned]) {
|
||||
expansions.push(RagService.QUERY_EXPANSION_DICTIONARY[cleaned])
|
||||
}
|
||||
}
|
||||
|
||||
if (expansions.length > 0) {
|
||||
expanded = `${expanded} ${expansions.join(' ')}`
|
||||
logger.debug(`[RAG] Query expanded with domain terms: "${expanded}"`)
|
||||
}
|
||||
|
||||
logger.debug(`[RAG] Original query: "${query}"`)
|
||||
logger.debug(`[RAG] Preprocessed query: "${expanded}"`)
|
||||
return expanded
|
||||
|
|
@ -179,7 +234,8 @@ export class RagService {
|
|||
|
||||
public async embedAndStoreText(
|
||||
text: string,
|
||||
metadata: Record<string, any> = {}
|
||||
metadata: Record<string, any> = {},
|
||||
onProgress?: (percent: number) => Promise<void>
|
||||
): Promise<{ chunks: number } | null> {
|
||||
try {
|
||||
await this._ensureCollection(
|
||||
|
|
@ -187,22 +243,26 @@ export class RagService {
|
|||
RagService.EMBEDDING_DIMENSION
|
||||
)
|
||||
|
||||
const allModels = await this.ollamaService.getModels(true)
|
||||
const embeddingModel = allModels.find((model) => model.name === RagService.EMBEDDING_MODEL)
|
||||
if (!this.embeddingModelVerified) {
|
||||
const allModels = await this.ollamaService.getModels(true)
|
||||
const embeddingModel = allModels.find((model) => model.name === RagService.EMBEDDING_MODEL)
|
||||
|
||||
if (!embeddingModel) {
|
||||
try {
|
||||
const downloadResult = await this.ollamaService.downloadModel(RagService.EMBEDDING_MODEL)
|
||||
if (!downloadResult.success) {
|
||||
throw new Error(downloadResult.message || 'Unknown error during model download')
|
||||
if (!embeddingModel) {
|
||||
try {
|
||||
const downloadResult = await this.ollamaService.downloadModel(RagService.EMBEDDING_MODEL)
|
||||
if (!downloadResult.success) {
|
||||
throw new Error(downloadResult.message || 'Unknown error during model download')
|
||||
}
|
||||
} catch (modelError) {
|
||||
logger.error(
|
||||
`[RAG] Embedding model ${RagService.EMBEDDING_MODEL} not found locally and failed to download:`,
|
||||
modelError
|
||||
)
|
||||
this.embeddingModelVerified = false
|
||||
return null
|
||||
}
|
||||
} catch (modelError) {
|
||||
logger.error(
|
||||
`[RAG] Embedding model ${RagService.EMBEDDING_MODEL} not found locally and failed to download:`,
|
||||
modelError
|
||||
)
|
||||
return null
|
||||
}
|
||||
this.embeddingModelVerified = true
|
||||
}
|
||||
|
||||
// TokenChunker uses character-based tokenization (1 char = 1 token)
|
||||
|
|
@ -227,7 +287,8 @@ export class RagService {
|
|||
|
||||
const ollamaClient = await this.ollamaService.getClient()
|
||||
|
||||
const embeddings: number[][] = []
|
||||
// Prepare all chunk texts with prefix and truncation
|
||||
const prefixedChunks: string[] = []
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
let chunkText = chunks[i]
|
||||
|
||||
|
|
@ -237,7 +298,6 @@ export class RagService {
|
|||
const estimatedTokens = this.estimateTokenCount(withPrefix)
|
||||
|
||||
if (estimatedTokens > RagService.MAX_SAFE_TOKENS) {
|
||||
// This should be rare - log for debugging if it's occurring frequently
|
||||
const prefixTokens = this.estimateTokenCount(prefixText)
|
||||
const maxTokensForText = RagService.MAX_SAFE_TOKENS - prefixTokens
|
||||
logger.warn(
|
||||
|
|
@ -246,14 +306,31 @@ export class RagService {
|
|||
chunkText = this.truncateToTokenLimit(chunkText, maxTokensForText)
|
||||
}
|
||||
|
||||
logger.debug(`[RAG] Generating embedding for chunk ${i + 1}/${chunks.length}`)
|
||||
prefixedChunks.push(RagService.SEARCH_DOCUMENT_PREFIX + chunkText)
|
||||
}
|
||||
|
||||
const response = await ollamaClient.embeddings({
|
||||
// Batch embed chunks for performance
|
||||
const embeddings: number[][] = []
|
||||
const batchSize = RagService.EMBEDDING_BATCH_SIZE
|
||||
const totalBatches = Math.ceil(prefixedChunks.length / batchSize)
|
||||
|
||||
for (let batchIdx = 0; batchIdx < totalBatches; batchIdx++) {
|
||||
const batchStart = batchIdx * batchSize
|
||||
const batch = prefixedChunks.slice(batchStart, batchStart + batchSize)
|
||||
|
||||
logger.debug(`[RAG] Embedding batch ${batchIdx + 1}/${totalBatches} (${batch.length} chunks)`)
|
||||
|
||||
const response = await ollamaClient.embed({
|
||||
model: RagService.EMBEDDING_MODEL,
|
||||
prompt: RagService.SEARCH_DOCUMENT_PREFIX + chunkText,
|
||||
input: batch,
|
||||
})
|
||||
|
||||
embeddings.push(response.embedding)
|
||||
embeddings.push(...response.embeddings)
|
||||
|
||||
if (onProgress) {
|
||||
const progress = ((batchStart + batch.length) / prefixedChunks.length) * 100
|
||||
await onProgress(progress)
|
||||
}
|
||||
}
|
||||
|
||||
const timestamp = Date.now()
|
||||
|
|
@ -389,15 +466,9 @@ export class RagService {
|
|||
private async processZIMFile(
|
||||
filepath: string,
|
||||
deleteAfterEmbedding: boolean,
|
||||
batchOffset?: number
|
||||
): Promise<{
|
||||
success: boolean
|
||||
message: string
|
||||
chunks?: number
|
||||
hasMoreBatches?: boolean
|
||||
articlesProcessed?: number
|
||||
totalArticles?: number
|
||||
}> {
|
||||
batchOffset?: number,
|
||||
onProgress?: (percent: number) => Promise<void>
|
||||
): Promise<ProcessZIMFileResponse> {
|
||||
const zimExtractionService = new ZIMExtractionService()
|
||||
|
||||
// Process in batches to avoid lock timeout
|
||||
|
|
@ -418,7 +489,8 @@ export class RagService {
|
|||
|
||||
// Process each chunk individually with its metadata
|
||||
let totalChunks = 0
|
||||
for (const zimChunk of zimChunks) {
|
||||
for (let i = 0; i < zimChunks.length; i++) {
|
||||
const zimChunk = zimChunks[i]
|
||||
const result = await this.embedAndStoreText(zimChunk.text, {
|
||||
source: filepath,
|
||||
content_type: 'zim_article',
|
||||
|
|
@ -451,6 +523,10 @@ export class RagService {
|
|||
if (result) {
|
||||
totalChunks += result.chunks
|
||||
}
|
||||
|
||||
if (onProgress) {
|
||||
await onProgress(((i + 1) / zimChunks.length) * 100)
|
||||
}
|
||||
}
|
||||
|
||||
// Count unique articles processed in this batch
|
||||
|
|
@ -491,7 +567,8 @@ export class RagService {
|
|||
private async embedTextAndCleanup(
|
||||
extractedText: string,
|
||||
filepath: string,
|
||||
deleteAfterEmbedding: boolean = false
|
||||
deleteAfterEmbedding: boolean = false,
|
||||
onProgress?: (percent: number) => Promise<void>
|
||||
): Promise<{ success: boolean; message: string; chunks?: number }> {
|
||||
if (!extractedText || extractedText.trim().length === 0) {
|
||||
return { success: false, message: 'Process completed succesfully, but no text was found to embed.' }
|
||||
|
|
@ -499,7 +576,7 @@ export class RagService {
|
|||
|
||||
const embedResult = await this.embedAndStoreText(extractedText, {
|
||||
source: filepath
|
||||
})
|
||||
}, onProgress)
|
||||
|
||||
if (!embedResult) {
|
||||
return { success: false, message: 'Failed to embed and store the extracted text.' }
|
||||
|
|
@ -527,15 +604,9 @@ export class RagService {
|
|||
public async processAndEmbedFile(
|
||||
filepath: string,
|
||||
deleteAfterEmbedding: boolean = false,
|
||||
batchOffset?: number
|
||||
): Promise<{
|
||||
success: boolean
|
||||
message: string
|
||||
chunks?: number
|
||||
hasMoreBatches?: boolean
|
||||
articlesProcessed?: number
|
||||
totalArticles?: number
|
||||
}> {
|
||||
batchOffset?: number,
|
||||
onProgress?: (percent: number) => Promise<void>
|
||||
): Promise<ProcessAndEmbedFileResponse> {
|
||||
try {
|
||||
const fileType = determineFileType(filepath)
|
||||
logger.debug(`[RAG] Processing file: ${filepath} (detected type: ${fileType})`)
|
||||
|
|
@ -553,10 +624,12 @@ export class RagService {
|
|||
// Process based on file type
|
||||
// ZIM files are handled specially since they have their own embedding workflow
|
||||
if (fileType === 'zim') {
|
||||
return await this.processZIMFile(filepath, deleteAfterEmbedding, batchOffset)
|
||||
return await this.processZIMFile(filepath, deleteAfterEmbedding, batchOffset, onProgress)
|
||||
}
|
||||
|
||||
// Extract text based on file type
|
||||
// Report ~10% when extraction begins; actual embedding progress follows via callback
|
||||
if (onProgress) await onProgress(10)
|
||||
let extractedText: string
|
||||
switch (fileType) {
|
||||
case 'image':
|
||||
|
|
@ -571,8 +644,14 @@ export class RagService {
|
|||
break
|
||||
}
|
||||
|
||||
// Extraction done — scale remaining embedding progress from 15% to 100%
|
||||
if (onProgress) await onProgress(15)
|
||||
const scaledProgress = onProgress
|
||||
? (p: number) => onProgress(15 + p * 0.85)
|
||||
: undefined
|
||||
|
||||
// Embed extracted text and cleanup
|
||||
return await this.embedTextAndCleanup(extractedText, filepath, deleteAfterEmbedding)
|
||||
return await this.embedTextAndCleanup(extractedText, filepath, deleteAfterEmbedding, scaledProgress)
|
||||
} catch (error) {
|
||||
logger.error('[RAG] Error processing and embedding file:', error)
|
||||
return { success: false, message: 'Error processing and embedding file.' }
|
||||
|
|
@ -611,14 +690,18 @@ export class RagService {
|
|||
return []
|
||||
}
|
||||
|
||||
const allModels = await this.ollamaService.getModels(true)
|
||||
const embeddingModel = allModels.find((model) => model.name === RagService.EMBEDDING_MODEL)
|
||||
if (!this.embeddingModelVerified) {
|
||||
const allModels = await this.ollamaService.getModels(true)
|
||||
const embeddingModel = allModels.find((model) => model.name === RagService.EMBEDDING_MODEL)
|
||||
|
||||
if (!embeddingModel) {
|
||||
logger.warn(
|
||||
`[RAG] ${RagService.EMBEDDING_MODEL} not found. Cannot perform similarity search.`
|
||||
)
|
||||
return []
|
||||
if (!embeddingModel) {
|
||||
logger.warn(
|
||||
`[RAG] ${RagService.EMBEDDING_MODEL} not found. Cannot perform similarity search.`
|
||||
)
|
||||
this.embeddingModelVerified = false
|
||||
return []
|
||||
}
|
||||
this.embeddingModelVerified = true
|
||||
}
|
||||
|
||||
// Preprocess query for better matching
|
||||
|
|
@ -646,9 +729,9 @@ export class RagService {
|
|||
return []
|
||||
}
|
||||
|
||||
const response = await ollamaClient.embeddings({
|
||||
const response = await ollamaClient.embed({
|
||||
model: RagService.EMBEDDING_MODEL,
|
||||
prompt: prefixedQuery,
|
||||
input: [prefixedQuery],
|
||||
})
|
||||
|
||||
// Perform semantic search with a higher limit to enable reranking
|
||||
|
|
@ -658,7 +741,7 @@ export class RagService {
|
|||
)
|
||||
|
||||
const searchResults = await this.qdrant!.search(RagService.CONTENT_COLLECTION_NAME, {
|
||||
vector: response.embedding,
|
||||
vector: response.embeddings[0],
|
||||
limit: searchLimit,
|
||||
score_threshold: scoreThreshold,
|
||||
with_payload: true,
|
||||
|
|
@ -667,7 +750,7 @@ export class RagService {
|
|||
logger.debug(`[RAG] Found ${searchResults.length} results above threshold ${scoreThreshold}`)
|
||||
|
||||
// Map results with metadata for reranking
|
||||
const resultsWithMetadata = searchResults.map((result) => ({
|
||||
const resultsWithMetadata: RAGResult[] = searchResults.map((result) => ({
|
||||
text: (result.payload?.text as string) || '',
|
||||
score: result.score,
|
||||
keywords: (result.payload?.keywords as string) || '',
|
||||
|
|
@ -680,6 +763,7 @@ export class RagService {
|
|||
hierarchy: result.payload?.hierarchy as string | undefined,
|
||||
document_id: result.payload?.document_id as string | undefined,
|
||||
content_type: result.payload?.content_type as string | undefined,
|
||||
source: result.payload?.source as string | undefined,
|
||||
}))
|
||||
|
||||
const rerankedResults = this.rerankResults(resultsWithMetadata, keywords, query)
|
||||
|
|
@ -691,8 +775,11 @@ export class RagService {
|
|||
)
|
||||
})
|
||||
|
||||
// Apply source diversity penalty to avoid all results from the same document
|
||||
const diverseResults = this.applySourceDiversity(rerankedResults)
|
||||
|
||||
// Return top N results with enhanced metadata
|
||||
return rerankedResults.slice(0, limit).map((result) => ({
|
||||
return diverseResults.slice(0, limit).map((result) => ({
|
||||
text: result.text,
|
||||
score: result.finalScore,
|
||||
metadata: {
|
||||
|
|
@ -728,34 +815,10 @@ export class RagService {
|
|||
* outweigh the overhead.
|
||||
*/
|
||||
private rerankResults(
|
||||
results: Array<{
|
||||
text: string
|
||||
score: number
|
||||
keywords: string
|
||||
chunk_index: number
|
||||
created_at: number
|
||||
article_title?: string
|
||||
section_title?: string
|
||||
full_title?: string
|
||||
hierarchy?: string
|
||||
document_id?: string
|
||||
content_type?: string
|
||||
}>,
|
||||
results: Array<RAGResult>,
|
||||
queryKeywords: string[],
|
||||
originalQuery: string
|
||||
): Array<{
|
||||
text: string
|
||||
score: number
|
||||
finalScore: number
|
||||
chunk_index: number
|
||||
created_at: number
|
||||
article_title?: string
|
||||
section_title?: string
|
||||
full_title?: string
|
||||
hierarchy?: string
|
||||
document_id?: string
|
||||
content_type?: string
|
||||
}> {
|
||||
): Array<RerankedRAGResult> {
|
||||
return results
|
||||
.map((result) => {
|
||||
let finalScore = result.score
|
||||
|
|
@ -831,9 +894,40 @@ export class RagService {
|
|||
.sort((a, b) => b.finalScore - a.finalScore)
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a diversity penalty so results from the same source are down-weighted.
|
||||
* Uses greedy selection: for each result, apply 0.85^n penalty where n is the
|
||||
* number of results already selected from the same source.
|
||||
*/
|
||||
private applySourceDiversity(
|
||||
results: Array<RerankedRAGResult>
|
||||
) {
|
||||
const sourceCounts = new Map<string, number>()
|
||||
const DIVERSITY_PENALTY = 0.85
|
||||
|
||||
return results
|
||||
.map((result) => {
|
||||
const sourceKey = result.document_id || result.source || 'unknown'
|
||||
const count = sourceCounts.get(sourceKey) || 0
|
||||
const penalty = Math.pow(DIVERSITY_PENALTY, count)
|
||||
const diverseScore = result.finalScore * penalty
|
||||
|
||||
sourceCounts.set(sourceKey, count + 1)
|
||||
|
||||
if (count > 0) {
|
||||
logger.debug(
|
||||
`[RAG] Source diversity penalty for "${sourceKey}": ${result.finalScore.toFixed(4)} → ${diverseScore.toFixed(4)} (seen ${count}x)`
|
||||
)
|
||||
}
|
||||
|
||||
return { ...result, finalScore: diverseScore }
|
||||
})
|
||||
.sort((a, b) => b.finalScore - a.finalScore)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve all unique source files that have been stored in the knowledge base.
|
||||
* @returns Array of unique source file identifiers
|
||||
* @returns Array of unique full source paths
|
||||
*/
|
||||
public async getStoredFiles(): Promise<string[]> {
|
||||
try {
|
||||
|
|
@ -846,12 +940,12 @@ export class RagService {
|
|||
let offset: string | number | null | Record<string, unknown> = null
|
||||
const batchSize = 100
|
||||
|
||||
// Scroll through all points in the collection
|
||||
// Scroll through all points in the collection (only fetch source field)
|
||||
do {
|
||||
const scrollResult = await this.qdrant!.scroll(RagService.CONTENT_COLLECTION_NAME, {
|
||||
limit: batchSize,
|
||||
offset: offset,
|
||||
with_payload: true,
|
||||
with_payload: ['source'],
|
||||
with_vector: false,
|
||||
})
|
||||
|
||||
|
|
@ -866,26 +960,61 @@ export class RagService {
|
|||
offset = scrollResult.next_page_offset || null
|
||||
} while (offset !== null)
|
||||
|
||||
const sourcesArr = Array.from(sources)
|
||||
|
||||
// The source is a full path - only extract the filename for display
|
||||
return sourcesArr.map((src) => {
|
||||
const parts = src.split(/[/\\]/)
|
||||
return parts[parts.length - 1] // Return the last part as filename
|
||||
})
|
||||
return Array.from(sources)
|
||||
} catch (error) {
|
||||
logger.error('Error retrieving stored files:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all Qdrant points associated with a given source path and remove
|
||||
* the corresponding file from disk if it lives under the uploads directory.
|
||||
* @param source - Full source path as stored in Qdrant payloads
|
||||
*/
|
||||
public async deleteFileBySource(source: string): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
await this._ensureCollection(
|
||||
RagService.CONTENT_COLLECTION_NAME,
|
||||
RagService.EMBEDDING_DIMENSION
|
||||
)
|
||||
|
||||
await this.qdrant!.delete(RagService.CONTENT_COLLECTION_NAME, {
|
||||
filter: {
|
||||
must: [{ key: 'source', match: { value: source } }],
|
||||
},
|
||||
})
|
||||
|
||||
logger.info(`[RAG] Deleted all points for source: ${source}`)
|
||||
|
||||
/** Delete the physical file only if it lives inside the uploads directory.
|
||||
* resolve() normalises path traversal sequences (e.g. "/../..") before the
|
||||
* check to prevent path traversal vulns
|
||||
* The trailing sep is to ensure a prefix like "kb_uploads_{something_incorrect}" can't slip through.
|
||||
*/
|
||||
const uploadsAbsPath = join(process.cwd(), RagService.UPLOADS_STORAGE_PATH)
|
||||
const resolvedSource = resolve(source)
|
||||
if (resolvedSource.startsWith(uploadsAbsPath + sep)) {
|
||||
await deleteFileIfExists(resolvedSource)
|
||||
logger.info(`[RAG] Deleted uploaded file from disk: ${resolvedSource}`)
|
||||
} else {
|
||||
logger.warn(`[RAG] File was removed from knowledge base but doesn't live in Nomad's uploads directory, so it can't be safely removed. Skipping deletion of physical file...`)
|
||||
}
|
||||
|
||||
return { success: true, message: 'File removed from knowledge base.' }
|
||||
} catch (error) {
|
||||
logger.error('[RAG] Error deleting file from knowledge base:', error)
|
||||
return { success: false, message: 'Error deleting file from knowledge base.' }
|
||||
}
|
||||
}
|
||||
|
||||
public async discoverNomadDocs(force?: boolean): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const README_PATH = join(process.cwd(), 'README.md')
|
||||
const DOCS_DIR = join(process.cwd(), 'docs')
|
||||
|
||||
const alreadyEmbeddedRaw = await KVStore.getValue('rag.docsEmbedded')
|
||||
if (parseBoolean(alreadyEmbeddedRaw) && !force) {
|
||||
if (alreadyEmbeddedRaw && !force) {
|
||||
logger.info('[RAG] Nomad docs have already been discovered and queued. Skipping.')
|
||||
return { success: true, message: 'Nomad docs have already been discovered and queued. Skipping.' }
|
||||
}
|
||||
|
|
@ -927,7 +1056,7 @@ export class RagService {
|
|||
}
|
||||
|
||||
// Update KV store to mark docs as discovered so we don't redo this unnecessarily
|
||||
await KVStore.setValue('rag.docsEmbedded', 'true')
|
||||
await KVStore.setValue('rag.docsEmbedded', true)
|
||||
|
||||
return { success: true, message: `Nomad docs discovery completed. Dispatched ${filesToEmbed.length} embedding jobs.` }
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { DockerService } from '#services/docker_service'
|
|||
import { ServiceSlim } from '../../types/services.js'
|
||||
import logger from '@adonisjs/core/services/logger'
|
||||
import si from 'systeminformation'
|
||||
import { NomadDiskInfo, NomadDiskInfoRaw, SystemInformationResponse } from '../../types/system.js'
|
||||
import { GpuHealthStatus, NomadDiskInfo, NomadDiskInfoRaw, SystemInformationResponse } from '../../types/system.js'
|
||||
import { SERVICE_NAMES } from '../../constants/service_names.js'
|
||||
import { readFileSync } from 'fs'
|
||||
import path, { join } from 'path'
|
||||
|
|
@ -12,8 +12,9 @@ import { getAllFilesystems, getFile } from '../utils/fs.js'
|
|||
import axios from 'axios'
|
||||
import env from '#start/env'
|
||||
import KVStore from '#models/kv_store'
|
||||
import { KVStoreKey } from '../../types/kv_store.js'
|
||||
import { parseBoolean } from '../utils/misc.js'
|
||||
import { KV_STORE_SCHEMA, KVStoreKey } from '../../types/kv_store.js'
|
||||
import { isNewerVersion } from '../utils/version.js'
|
||||
|
||||
|
||||
@inject()
|
||||
export class SystemService {
|
||||
|
|
@ -142,7 +143,9 @@ export class SystemService {
|
|||
'description',
|
||||
'icon',
|
||||
'powered_by',
|
||||
'display_order'
|
||||
'display_order',
|
||||
'container_image',
|
||||
'available_update_version'
|
||||
)
|
||||
.where('is_dependency_service', false)
|
||||
if (installedOnly) {
|
||||
|
|
@ -172,6 +175,8 @@ export class SystemService {
|
|||
ui_location: service.ui_location || '',
|
||||
powered_by: service.powered_by,
|
||||
display_order: service.display_order,
|
||||
container_image: service.container_image,
|
||||
available_update_version: service.available_update_version,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -235,6 +240,13 @@ export class SystemService {
|
|||
logger.error('Error reading disk info file:', error)
|
||||
}
|
||||
|
||||
// GPU health tracking — detect when host has NVIDIA GPU but Ollama can't access it
|
||||
let gpuHealth: GpuHealthStatus = {
|
||||
status: 'no_gpu',
|
||||
hasNvidiaRuntime: false,
|
||||
ollamaGpuAccessible: false,
|
||||
}
|
||||
|
||||
// Query Docker API for host-level info (hostname, OS, GPU runtime)
|
||||
// si.osInfo() returns the container's info inside Docker, not the host's
|
||||
try {
|
||||
|
|
@ -255,6 +267,7 @@ export class SystemService {
|
|||
if (!graphics.controllers || graphics.controllers.length === 0) {
|
||||
const runtimes = dockerInfo.Runtimes || {}
|
||||
if ('nvidia' in runtimes) {
|
||||
gpuHealth.hasNvidiaRuntime = true
|
||||
const nvidiaInfo = await this.getNvidiaSmiInfo()
|
||||
if (Array.isArray(nvidiaInfo)) {
|
||||
graphics.controllers = nvidiaInfo.map((gpu) => ({
|
||||
|
|
@ -264,10 +277,19 @@ export class SystemService {
|
|||
vram: gpu.vram,
|
||||
vramDynamic: false, // assume false here, we don't actually use this field for our purposes.
|
||||
}))
|
||||
gpuHealth.status = 'ok'
|
||||
gpuHealth.ollamaGpuAccessible = true
|
||||
} else if (nvidiaInfo === 'OLLAMA_NOT_FOUND') {
|
||||
gpuHealth.status = 'ollama_not_installed'
|
||||
} else {
|
||||
logger.warn(`NVIDIA runtime detected but failed to get GPU info: ${typeof nvidiaInfo === 'string' ? nvidiaInfo : JSON.stringify(nvidiaInfo)}`)
|
||||
gpuHealth.status = 'passthrough_failed'
|
||||
logger.warn(`NVIDIA runtime detected but GPU passthrough failed: ${typeof nvidiaInfo === 'string' ? nvidiaInfo : JSON.stringify(nvidiaInfo)}`)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// si.graphics() returned controllers (host install, not Docker) — GPU is working
|
||||
gpuHealth.status = 'ok'
|
||||
gpuHealth.ollamaGpuAccessible = true
|
||||
}
|
||||
} catch {
|
||||
// Docker info query failed, skip host-level enrichment
|
||||
|
|
@ -282,6 +304,7 @@ export class SystemService {
|
|||
fsSize,
|
||||
uptime,
|
||||
graphics,
|
||||
gpuHealth,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error getting system info:', error)
|
||||
|
|
@ -306,33 +329,39 @@ export class SystemService {
|
|||
if (!force) {
|
||||
return {
|
||||
success: true,
|
||||
updateAvailable: parseBoolean(cachedUpdateAvailable || "false"),
|
||||
updateAvailable: cachedUpdateAvailable ?? false,
|
||||
currentVersion,
|
||||
latestVersion: cachedLatestVersion || '',
|
||||
}
|
||||
}
|
||||
|
||||
const response = await axios.get(
|
||||
'https://api.github.com/repos/Crosstalk-Solutions/project-nomad/releases/latest',
|
||||
{
|
||||
headers: { Accept: 'application/vnd.github+json' },
|
||||
timeout: 5000,
|
||||
}
|
||||
)
|
||||
const earlyAccess = (await KVStore.getValue('system.earlyAccess')) ?? false
|
||||
|
||||
if (!response || !response.data?.tag_name) {
|
||||
throw new Error('Invalid response from GitHub API')
|
||||
let latestVersion: string
|
||||
if (earlyAccess) {
|
||||
const response = await axios.get(
|
||||
'https://api.github.com/repos/Crosstalk-Solutions/project-nomad/releases',
|
||||
{ headers: { Accept: 'application/vnd.github+json' }, timeout: 5000 }
|
||||
)
|
||||
if (!response?.data?.length) throw new Error('No releases found')
|
||||
latestVersion = response.data[0].tag_name.replace(/^v/, '').trim()
|
||||
} else {
|
||||
const response = await axios.get(
|
||||
'https://api.github.com/repos/Crosstalk-Solutions/project-nomad/releases/latest',
|
||||
{ headers: { Accept: 'application/vnd.github+json' }, timeout: 5000 }
|
||||
)
|
||||
if (!response?.data?.tag_name) throw new Error('Invalid response from GitHub API')
|
||||
latestVersion = response.data.tag_name.replace(/^v/, '').trim()
|
||||
}
|
||||
|
||||
const latestVersion = response.data.tag_name.replace(/^v/, '').trim() // Remove leading 'v' and whitespace
|
||||
logger.info(`Current version: ${currentVersion}, Latest version: ${latestVersion}`)
|
||||
logger.info(`Current version: ${currentVersion}, Latest version: ${latestVersion}`)
|
||||
|
||||
const updateAvailable = process.env.NODE_ENV === 'development'
|
||||
? false
|
||||
: this.isNewerVersion(latestVersion, currentVersion.trim())
|
||||
const updateAvailable = process.env.NODE_ENV === 'development'
|
||||
? false
|
||||
: isNewerVersion(latestVersion, currentVersion.trim(), earlyAccess)
|
||||
|
||||
// Cache the results in KVStore for frontend checks
|
||||
await KVStore.setValue('system.updateAvailable', updateAvailable.toString())
|
||||
await KVStore.setValue('system.updateAvailable', updateAvailable)
|
||||
await KVStore.setValue('system.latestVersion', latestVersion)
|
||||
|
||||
return {
|
||||
|
|
@ -381,8 +410,123 @@ export class SystemService {
|
|||
}
|
||||
}
|
||||
|
||||
async getDebugInfo(): Promise<string> {
|
||||
const appVersion = SystemService.getAppVersion()
|
||||
const environment = process.env.NODE_ENV || 'unknown'
|
||||
|
||||
const [systemInfo, services, internetStatus, versionCheck] = await Promise.all([
|
||||
this.getSystemInfo(),
|
||||
this.getServices({ installedOnly: false }),
|
||||
this.getInternetStatus().catch(() => null),
|
||||
this.checkLatestVersion().catch(() => null),
|
||||
])
|
||||
|
||||
const lines: string[] = [
|
||||
'Project NOMAD Debug Info',
|
||||
'========================',
|
||||
`App Version: ${appVersion}`,
|
||||
`Environment: ${environment}`,
|
||||
]
|
||||
|
||||
if (systemInfo) {
|
||||
const { cpu, mem, os, disk, fsSize, uptime, graphics } = systemInfo
|
||||
|
||||
lines.push('')
|
||||
lines.push('System:')
|
||||
if (os.distro) lines.push(` OS: ${os.distro}`)
|
||||
if (os.hostname) lines.push(` Hostname: ${os.hostname}`)
|
||||
if (os.kernel) lines.push(` Kernel: ${os.kernel}`)
|
||||
if (os.arch) lines.push(` Architecture: ${os.arch}`)
|
||||
if (uptime?.uptime) lines.push(` Uptime: ${this._formatUptime(uptime.uptime)}`)
|
||||
|
||||
lines.push('')
|
||||
lines.push('Hardware:')
|
||||
if (cpu.brand) {
|
||||
lines.push(` CPU: ${cpu.brand} (${cpu.cores} cores)`)
|
||||
}
|
||||
if (mem.total) {
|
||||
const total = this._formatBytes(mem.total)
|
||||
const used = this._formatBytes(mem.total - (mem.available || 0))
|
||||
const available = this._formatBytes(mem.available || 0)
|
||||
lines.push(` RAM: ${total} total, ${used} used, ${available} available`)
|
||||
}
|
||||
if (graphics.controllers && graphics.controllers.length > 0) {
|
||||
for (const gpu of graphics.controllers) {
|
||||
const vram = gpu.vram ? ` (${gpu.vram} MB VRAM)` : ''
|
||||
lines.push(` GPU: ${gpu.model}${vram}`)
|
||||
}
|
||||
} else {
|
||||
lines.push(' GPU: None detected')
|
||||
}
|
||||
|
||||
// Disk info — try disk array first, fall back to fsSize
|
||||
const diskEntries = disk.filter((d) => d.totalSize > 0)
|
||||
if (diskEntries.length > 0) {
|
||||
for (const d of diskEntries) {
|
||||
const size = this._formatBytes(d.totalSize)
|
||||
const type = d.tran?.toUpperCase() || (d.rota ? 'HDD' : 'SSD')
|
||||
lines.push(` Disk: ${size}, ${Math.round(d.percentUsed)}% used, ${type}`)
|
||||
}
|
||||
} else if (fsSize.length > 0) {
|
||||
const realFs = fsSize.filter((f) => f.fs.startsWith('/dev/'))
|
||||
const seen = new Set<number>()
|
||||
for (const f of realFs) {
|
||||
if (seen.has(f.size)) continue
|
||||
seen.add(f.size)
|
||||
lines.push(` Disk: ${this._formatBytes(f.size)}, ${Math.round(f.use)}% used`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const installed = services.filter((s) => s.installed)
|
||||
lines.push('')
|
||||
if (installed.length > 0) {
|
||||
lines.push('Installed Services:')
|
||||
for (const svc of installed) {
|
||||
lines.push(` ${svc.friendly_name} (${svc.service_name}): ${svc.status}`)
|
||||
}
|
||||
} else {
|
||||
lines.push('Installed Services: None')
|
||||
}
|
||||
|
||||
if (internetStatus !== null) {
|
||||
lines.push('')
|
||||
lines.push(`Internet Status: ${internetStatus ? 'Online' : 'Offline'}`)
|
||||
}
|
||||
|
||||
if (versionCheck?.success) {
|
||||
const updateMsg = versionCheck.updateAvailable
|
||||
? `Yes (${versionCheck.latestVersion} available)`
|
||||
: `No (${versionCheck.currentVersion} is latest)`
|
||||
lines.push(`Update Available: ${updateMsg}`)
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
private _formatUptime(seconds: number): string {
|
||||
const days = Math.floor(seconds / 86400)
|
||||
const hours = Math.floor((seconds % 86400) / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
if (days > 0) return `${days}d ${hours}h ${minutes}m`
|
||||
if (hours > 0) return `${hours}h ${minutes}m`
|
||||
return `${minutes}m`
|
||||
}
|
||||
|
||||
private _formatBytes(bytes: number, decimals = 1): string {
|
||||
if (bytes === 0) return '0 Bytes'
|
||||
const k = 1024
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
async updateSetting(key: KVStoreKey, value: any): Promise<void> {
|
||||
await KVStore.setValue(key, value);
|
||||
if ((value === '' || value === undefined || value === null) && KV_STORE_SCHEMA[key] === 'string') {
|
||||
await KVStore.clearValue(key)
|
||||
} else {
|
||||
await KVStore.setValue(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -435,10 +579,21 @@ export class SystemService {
|
|||
return []
|
||||
}
|
||||
|
||||
// Deduplicate: same device path mounted in multiple places (Docker bind-mounts)
|
||||
// Keep the entry with the largest size — that's the real partition
|
||||
const deduped = new Map<string, NomadDiskInfoRaw['fsSize'][0]>()
|
||||
for (const entry of fsSize) {
|
||||
const existing = deduped.get(entry.fs)
|
||||
if (!existing || entry.size > existing.size) {
|
||||
deduped.set(entry.fs, entry)
|
||||
}
|
||||
}
|
||||
const dedupedFsSize = Array.from(deduped.values())
|
||||
|
||||
return diskLayout.blockdevices
|
||||
.filter((disk) => disk.type === 'disk') // Only physical disks
|
||||
.map((disk) => {
|
||||
const filesystems = getAllFilesystems(disk, fsSize)
|
||||
const filesystems = getAllFilesystems(disk, dedupedFsSize)
|
||||
|
||||
// Across all partitions
|
||||
const totalUsed = filesystems.reduce((sum, p) => sum + (p.used || 0), 0)
|
||||
|
|
@ -466,26 +621,4 @@ export class SystemService {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two semantic version strings to determine if the first is newer than the second.
|
||||
* @param version1 - The version to check (e.g., "1.25.0")
|
||||
* @param version2 - The current version (e.g., "1.24.0")
|
||||
* @returns true if version1 is newer than version2
|
||||
*/
|
||||
private isNewerVersion(version1: string, version2: string): boolean {
|
||||
const v1Parts = version1.split('.').map((part) => parseInt(part, 10))
|
||||
const v2Parts = version2.split('.').map((part) => parseInt(part, 10))
|
||||
|
||||
const maxLength = Math.max(v1Parts.length, v2Parts.length)
|
||||
|
||||
for (let i = 0; i < maxLength; i++) {
|
||||
const v1Part = v1Parts[i] || 0
|
||||
const v2Part = v2Parts[i] || 0
|
||||
|
||||
if (v1Part > v2Part) return true
|
||||
if (v1Part < v2Part) return false
|
||||
}
|
||||
|
||||
return false // Versions are equal
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import logger from '@adonisjs/core/services/logger'
|
|||
import { readFileSync, existsSync } from 'fs'
|
||||
import { writeFile } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import KVStore from '#models/kv_store'
|
||||
|
||||
interface UpdateStatus {
|
||||
stage: 'idle' | 'starting' | 'pulling' | 'pulled' | 'recreating' | 'complete' | 'error'
|
||||
|
|
@ -21,7 +22,7 @@ export class SystemUpdateService {
|
|||
*/
|
||||
async requestUpdate(): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const currentStatus = this.getUpdateStatus()
|
||||
const currentStatus = this.getUpdateStatus()
|
||||
if (currentStatus && !['idle', 'complete', 'error'].includes(currentStatus.stage)) {
|
||||
return {
|
||||
success: false,
|
||||
|
|
@ -29,13 +30,17 @@ export class SystemUpdateService {
|
|||
}
|
||||
}
|
||||
|
||||
// Determine the Docker image tag to install.
|
||||
const latestVersion = await KVStore.getValue('system.latestVersion')
|
||||
|
||||
const requestData = {
|
||||
requested_at: new Date().toISOString(),
|
||||
requester: 'admin-api',
|
||||
target_tag: latestVersion ? `v${latestVersion}` : 'latest',
|
||||
}
|
||||
|
||||
await writeFile(SystemUpdateService.REQUEST_FILE, JSON.stringify(requestData, null, 2))
|
||||
logger.info('[SystemUpdateService]: System update requested - sidecar will process shortly')
|
||||
logger.info(`[SystemUpdateService]: System update requested (target tag: ${requestData.target_tag}) - sidecar will process shortly`)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import {
|
|||
listDirectoryContents,
|
||||
ZIM_STORAGE_PATH,
|
||||
} from '../utils/fs.js'
|
||||
import { join } from 'path'
|
||||
import { join, resolve, sep } from 'path'
|
||||
import { WikipediaOption, WikipediaState } from '../../types/downloads.js'
|
||||
import vine from '@vinejs/vine'
|
||||
import { wikipediaOptionsFileSchema } from '#validators/curated_collections'
|
||||
|
|
@ -28,7 +28,7 @@ import { CollectionManifestService } from './collection_manifest_service.js'
|
|||
import type { CategoryWithStatus } from '../../types/collections.js'
|
||||
|
||||
const ZIM_MIME_TYPES = ['application/x-zim', 'application/x-openzim', 'application/octet-stream']
|
||||
const WIKIPEDIA_OPTIONS_URL = 'https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/master/collections/wikipedia.json'
|
||||
const WIKIPEDIA_OPTIONS_URL = 'https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/collections/wikipedia.json'
|
||||
|
||||
@inject()
|
||||
export class ZimService {
|
||||
|
|
@ -332,7 +332,13 @@ export class ZimService {
|
|||
fileName += '.zim'
|
||||
}
|
||||
|
||||
const fullPath = join(process.cwd(), ZIM_STORAGE_PATH, fileName)
|
||||
const basePath = resolve(join(process.cwd(), ZIM_STORAGE_PATH))
|
||||
const fullPath = resolve(join(basePath, fileName))
|
||||
|
||||
// Prevent path traversal — resolved path must stay within the storage directory
|
||||
if (!fullPath.startsWith(basePath + sep)) {
|
||||
throw new Error('Invalid filename')
|
||||
}
|
||||
|
||||
const exists = await getFileStatsIfExists(fullPath)
|
||||
if (!exists) {
|
||||
|
|
|
|||
|
|
@ -88,10 +88,29 @@ export async function doResumableDownload({
|
|||
let lastProgressTime = Date.now()
|
||||
let lastDownloadedBytes = startByte
|
||||
|
||||
// Stall detection: if no data arrives for 5 minutes, abort the download
|
||||
const STALL_TIMEOUT_MS = 5 * 60 * 1000
|
||||
let stallTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const clearStallTimer = () => {
|
||||
if (stallTimer) {
|
||||
clearTimeout(stallTimer)
|
||||
stallTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const resetStallTimer = () => {
|
||||
clearStallTimer()
|
||||
stallTimer = setTimeout(() => {
|
||||
cleanup(new Error('Download stalled - no data received for 5 minutes'))
|
||||
}, STALL_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
// Progress tracking stream to monitor data flow
|
||||
const progressStream = new Transform({
|
||||
transform(chunk: Buffer, _: any, callback: Function) {
|
||||
downloadedBytes += chunk.length
|
||||
resetStallTimer()
|
||||
|
||||
// Update progress tracking
|
||||
const now = Date.now()
|
||||
|
|
@ -118,6 +137,7 @@ export async function doResumableDownload({
|
|||
|
||||
// Handle errors and cleanup
|
||||
const cleanup = (error?: Error) => {
|
||||
clearStallTimer()
|
||||
progressStream.destroy()
|
||||
response.data.destroy()
|
||||
writeStream.destroy()
|
||||
|
|
@ -136,6 +156,7 @@ export async function doResumableDownload({
|
|||
})
|
||||
|
||||
writeStream.on('finish', async () => {
|
||||
clearStallTimer()
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
downloadedBytes,
|
||||
|
|
@ -151,7 +172,8 @@ export async function doResumableDownload({
|
|||
resolve(filepath)
|
||||
})
|
||||
|
||||
// Pipe: response -> progressStream -> writeStream
|
||||
// Start stall timer and pipe: response -> progressStream -> writeStream
|
||||
resetStallTimer()
|
||||
response.data.pipe(progressStream).pipe(writeStream)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -138,14 +138,13 @@ export function matchesDevice(fsPath: string, deviceName: string): boolean {
|
|||
// Remove /dev/ and /dev/mapper/ prefixes
|
||||
const normalized = fsPath.replace('/dev/mapper/', '').replace('/dev/', '')
|
||||
|
||||
// Direct match
|
||||
// Direct match (covers /dev/sda1 ↔ sda1, /dev/nvme0n1p1 ↔ nvme0n1p1)
|
||||
if (normalized === deviceName) {
|
||||
return true
|
||||
}
|
||||
|
||||
// LVM volumes use dashes instead of slashes
|
||||
// e.g., ubuntu--vg-ubuntu--lv matches the device name
|
||||
if (fsPath.includes(deviceName)) {
|
||||
// LVM/device-mapper: e.g., /dev/mapper/ubuntu--vg-ubuntu--lv contains "ubuntu--lv"
|
||||
if (fsPath.startsWith('/dev/mapper/') && fsPath.includes(deviceName)) {
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
|
|||
49
admin/app/utils/version.ts
Normal file
49
admin/app/utils/version.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
/**
|
||||
* Compare two semantic version strings to determine if the first is newer than the second.
|
||||
* @param version1 - The version to check (e.g., "1.25.0")
|
||||
* @param version2 - The current version (e.g., "1.24.0")
|
||||
* @returns true if version1 is newer than version2
|
||||
*/
|
||||
export function isNewerVersion(version1: string, version2: string, includePreReleases = false): boolean {
|
||||
const normalize = (v: string) => v.replace(/^v/, '')
|
||||
const [base1, pre1] = normalize(version1).split('-')
|
||||
const [base2, pre2] = normalize(version2).split('-')
|
||||
|
||||
// If pre-releases are not included and version1 is a pre-release, don't consider it newer
|
||||
if (!includePreReleases && pre1) {
|
||||
return false
|
||||
}
|
||||
|
||||
const v1Parts = base1.split('.').map((p) => parseInt(p, 10) || 0)
|
||||
const v2Parts = base2.split('.').map((p) => parseInt(p, 10) || 0)
|
||||
|
||||
const maxLen = Math.max(v1Parts.length, v2Parts.length)
|
||||
for (let i = 0; i < maxLen; i++) {
|
||||
const a = v1Parts[i] || 0
|
||||
const b = v2Parts[i] || 0
|
||||
if (a > b) return true
|
||||
if (a < b) return false
|
||||
}
|
||||
|
||||
// Base versions equal — GA > RC, RC.n+1 > RC.n
|
||||
if (!pre1 && pre2) return true // v1 is GA, v2 is RC → v1 is newer
|
||||
if (pre1 && !pre2) return false // v1 is RC, v2 is GA → v2 is newer
|
||||
if (!pre1 && !pre2) return false // both GA, equal
|
||||
|
||||
// Both prerelease: compare numeric suffix (e.g. "rc.2" vs "rc.1")
|
||||
const pre1Num = parseInt(pre1.split('.')[1], 10) || 0
|
||||
const pre2Num = parseInt(pre2.split('.')[1], 10) || 0
|
||||
return pre1Num > pre2Num
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the major version number from a tag string.
|
||||
* Strips the 'v' prefix if present.
|
||||
* @param tag - Version tag (e.g., "v3.8.1", "10.19.4")
|
||||
* @returns The major version number
|
||||
*/
|
||||
export function parseMajorVersion(tag: string): number {
|
||||
const normalized = tag.replace(/^v/, '')
|
||||
const major = parseInt(normalized.split('.')[0], 10)
|
||||
return isNaN(major) ? 0 : major
|
||||
}
|
||||
|
|
@ -1,12 +1,39 @@
|
|||
import vine from '@vinejs/vine'
|
||||
|
||||
/**
|
||||
* Checks whether a URL points to a loopback or link-local address.
|
||||
* Used to prevent SSRF — the server should not fetch from localhost
|
||||
* or link-local/metadata endpoints (e.g. cloud instance metadata at 169.254.x.x).
|
||||
*
|
||||
* RFC1918 private ranges (10.x, 172.16-31.x, 192.168.x) are intentionally
|
||||
* ALLOWED because NOMAD is a LAN appliance and users may host content
|
||||
* mirrors on their local network.
|
||||
*
|
||||
* Throws an error if the URL is a loopback or link-local address.
|
||||
*/
|
||||
export function assertNotPrivateUrl(urlString: string): void {
|
||||
const parsed = new URL(urlString)
|
||||
const hostname = parsed.hostname.toLowerCase()
|
||||
|
||||
const blockedPatterns = [
|
||||
/^localhost$/,
|
||||
/^127\.\d+\.\d+\.\d+$/,
|
||||
/^0\.0\.0\.0$/,
|
||||
/^169\.254\.\d+\.\d+$/, // Link-local / cloud metadata
|
||||
/^\[::1\]$/,
|
||||
/^\[?fe80:/i, // IPv6 link-local
|
||||
]
|
||||
|
||||
if (blockedPatterns.some((re) => re.test(hostname))) {
|
||||
throw new Error(`Download URL must not point to a loopback or link-local address: ${hostname}`)
|
||||
}
|
||||
}
|
||||
|
||||
export const remoteDownloadValidator = vine.compile(
|
||||
vine.object({
|
||||
url: vine
|
||||
.string()
|
||||
.url({
|
||||
require_tld: false, // Allow local URLs
|
||||
})
|
||||
.url({ require_tld: false }) // Allow LAN URLs (e.g. http://my-nas:8080/file.zim)
|
||||
.trim(),
|
||||
})
|
||||
)
|
||||
|
|
@ -15,9 +42,7 @@ export const remoteDownloadWithMetadataValidator = vine.compile(
|
|||
vine.object({
|
||||
url: vine
|
||||
.string()
|
||||
.url({
|
||||
require_tld: false, // Allow local URLs
|
||||
})
|
||||
.url({ require_tld: false }) // Allow LAN URLs
|
||||
.trim(),
|
||||
metadata: vine
|
||||
.object({
|
||||
|
|
@ -34,9 +59,7 @@ export const remoteDownloadValidatorOptional = vine.compile(
|
|||
vine.object({
|
||||
url: vine
|
||||
.string()
|
||||
.url({
|
||||
require_tld: false, // Allow local URLs
|
||||
})
|
||||
.url({ require_tld: false }) // Allow LAN URLs
|
||||
.trim()
|
||||
.optional(),
|
||||
})
|
||||
|
|
@ -74,7 +97,7 @@ const resourceUpdateInfoBase = vine.object({
|
|||
resource_type: vine.enum(['zim', 'map'] as const),
|
||||
installed_version: vine.string().trim(),
|
||||
latest_version: vine.string().trim().minLength(1),
|
||||
download_url: vine.string().url().trim(),
|
||||
download_url: vine.string().url({ require_tld: false }).trim(),
|
||||
})
|
||||
|
||||
export const applyContentUpdateValidator = vine.compile(resourceUpdateInfoBase)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export const chatSchema = vine.compile(
|
|||
})
|
||||
),
|
||||
stream: vine.boolean().optional(),
|
||||
sessionId: vine.number().positive().optional(),
|
||||
})
|
||||
)
|
||||
|
||||
|
|
@ -18,5 +19,7 @@ export const getAvailableModelsSchema = vine.compile(
|
|||
sort: vine.enum(['pulls', 'name'] as const).optional(),
|
||||
recommendedOnly: vine.boolean().optional(),
|
||||
query: vine.string().trim().optional(),
|
||||
limit: vine.number().positive().optional(),
|
||||
force: vine.boolean().optional(),
|
||||
})
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,3 +5,9 @@ export const getJobStatusSchema = vine.compile(
|
|||
filePath: vine.string(),
|
||||
})
|
||||
)
|
||||
|
||||
export const deleteFileSchema = vine.compile(
|
||||
vine.object({
|
||||
source: vine.string(),
|
||||
})
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,5 +4,5 @@ import { SETTINGS_KEYS } from "../../constants/kv_store.js";
|
|||
|
||||
export const updateSettingSchema = vine.compile(vine.object({
|
||||
key: vine.enum(SETTINGS_KEYS),
|
||||
value: vine.any(),
|
||||
value: vine.any().optional(),
|
||||
}))
|
||||
|
|
@ -24,3 +24,10 @@ export const checkLatestVersionValidator = vine.compile(
|
|||
force: vine.boolean().optional(), // Optional flag to force bypassing cache and checking for updates immediately
|
||||
})
|
||||
)
|
||||
|
||||
export const updateServiceValidator = vine.compile(
|
||||
vine.object({
|
||||
service_name: vine.string().trim(),
|
||||
target_version: vine.string().trim(),
|
||||
})
|
||||
)
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { DownloadModelJob } from '#jobs/download_model_job'
|
|||
import { RunBenchmarkJob } from '#jobs/run_benchmark_job'
|
||||
import { EmbedFileJob } from '#jobs/embed_file_job'
|
||||
import { CheckUpdateJob } from '#jobs/check_update_job'
|
||||
import { CheckServiceUpdatesJob } from '#jobs/check_service_updates_job'
|
||||
|
||||
export default class QueueWork extends BaseCommand {
|
||||
static commandName = 'queue:work'
|
||||
|
|
@ -64,8 +65,23 @@ export default class QueueWork extends BaseCommand {
|
|||
}
|
||||
)
|
||||
|
||||
worker.on('failed', (job, err) => {
|
||||
worker.on('failed', async (job, err) => {
|
||||
this.logger.error(`[${queueName}] Job failed: ${job?.id}, Error: ${err.message}`)
|
||||
|
||||
// If this was a Wikipedia download, mark it as failed in the DB
|
||||
if (job?.data?.filetype === 'zim' && job?.data?.url?.includes('wikipedia_en_')) {
|
||||
try {
|
||||
const { DockerService } = await import('#services/docker_service')
|
||||
const { ZimService } = await import('#services/zim_service')
|
||||
const dockerService = new DockerService()
|
||||
const zimService = new ZimService(dockerService)
|
||||
await zimService.onWikipediaDownloadComplete(job.data.url, false)
|
||||
} catch (e: any) {
|
||||
this.logger.error(
|
||||
`[${queueName}] Failed to update Wikipedia status: ${e.message}`
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
worker.on('completed', (job) => {
|
||||
|
|
@ -76,8 +92,9 @@ export default class QueueWork extends BaseCommand {
|
|||
this.logger.info(`Worker started for queue: ${queueName}`)
|
||||
}
|
||||
|
||||
// Schedule nightly update check (idempotent, will persist over restarts)
|
||||
// Schedule nightly update checks (idempotent, will persist over restarts)
|
||||
await CheckUpdateJob.scheduleNightly()
|
||||
await CheckServiceUpdatesJob.scheduleNightly()
|
||||
|
||||
// Graceful shutdown for all workers
|
||||
process.on('SIGTERM', async () => {
|
||||
|
|
@ -97,12 +114,14 @@ export default class QueueWork extends BaseCommand {
|
|||
handlers.set(RunBenchmarkJob.key, new RunBenchmarkJob())
|
||||
handlers.set(EmbedFileJob.key, new EmbedFileJob())
|
||||
handlers.set(CheckUpdateJob.key, new CheckUpdateJob())
|
||||
handlers.set(CheckServiceUpdatesJob.key, new CheckServiceUpdatesJob())
|
||||
|
||||
queues.set(RunDownloadJob.key, RunDownloadJob.queue)
|
||||
queues.set(DownloadModelJob.key, DownloadModelJob.queue)
|
||||
queues.set(RunBenchmarkJob.key, RunBenchmarkJob.queue)
|
||||
queues.set(EmbedFileJob.key, EmbedFileJob.queue)
|
||||
queues.set(CheckUpdateJob.key, CheckUpdateJob.queue)
|
||||
queues.set(CheckServiceUpdatesJob.key, CheckServiceUpdatesJob.queue)
|
||||
|
||||
return [handlers, queues]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import KVStore from '#models/kv_store'
|
||||
import { SystemService } from '#services/system_service'
|
||||
import { defineConfig } from '@adonisjs/inertia'
|
||||
import type { InferSharedProps } from '@adonisjs/inertia/types'
|
||||
|
|
@ -14,6 +15,10 @@ const inertiaConfig = defineConfig({
|
|||
sharedData: {
|
||||
appVersion: () => SystemService.getAppVersion(),
|
||||
environment: process.env.NODE_ENV || 'production',
|
||||
aiAssistantName: async () => {
|
||||
const customName = await KVStore.getValue('ai.assistantCustomName')
|
||||
return (customName && customName.trim()) ? customName : 'AI Assistant'
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ const loggerConfig = defineConfig({
|
|||
targets:
|
||||
targets()
|
||||
.pushIf(!app.inProduction, targets.pretty())
|
||||
.pushIf(app.inProduction, targets.file({ destination: "/app/storage/logs/admin.log" }))
|
||||
.pushIf(app.inProduction, targets.file({ destination: "/app/storage/logs/admin.log", mkdir: true }))
|
||||
.toArray(),
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { defineConfig } from '@adonisjs/transmit'
|
|||
import { redis } from '@adonisjs/transmit/transports'
|
||||
|
||||
export default defineConfig({
|
||||
pingInterval: false,
|
||||
pingInterval: '30s',
|
||||
transport: {
|
||||
driver: redis({
|
||||
host: env.get('REDIS_HOST'),
|
||||
|
|
|
|||
|
|
@ -3,4 +3,5 @@ export const BROADCAST_CHANNELS = {
|
|||
BENCHMARK_PROGRESS: 'benchmark-progress',
|
||||
OLLAMA_MODEL_DOWNLOAD: 'ollama-model-download',
|
||||
SERVICE_INSTALLATION: 'service-installation',
|
||||
SERVICE_UPDATES: 'service-updates',
|
||||
}
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
import { KVStoreKey } from "../types/kv_store.js";
|
||||
|
||||
export const SETTINGS_KEYS: KVStoreKey[] = ['chat.suggestionsEnabled', 'ui.hasVisitedEasySetup'];
|
||||
export const SETTINGS_KEYS: KVStoreKey[] = ['chat.suggestionsEnabled', 'chat.lastModel', 'ui.hasVisitedEasySetup', 'ui.theme', 'system.earlyAccess', 'ai.assistantCustomName'];
|
||||
|
|
@ -62,6 +62,18 @@ export const FALLBACK_RECOMMENDED_OLLAMA_MODELS: NomadOllamaModel[] = [
|
|||
},
|
||||
]
|
||||
|
||||
export const DEFAULT_QUERY_REWRITE_MODEL = 'qwen2.5:3b' // default to qwen2.5 for query rewriting with good balance of text task performance and resource usage
|
||||
|
||||
/**
|
||||
* Adaptive RAG context limits based on model size.
|
||||
* Smaller models get overwhelmed with too much context, so we cap it.
|
||||
*/
|
||||
export const RAG_CONTEXT_LIMITS: { maxParams: number; maxResults: number; maxTokens: number }[] = [
|
||||
{ maxParams: 3, maxResults: 2, maxTokens: 1000 }, // 1-3B models
|
||||
{ maxParams: 8, maxResults: 4, maxTokens: 2500 }, // 4-8B models
|
||||
{ maxParams: Infinity, maxResults: 5, maxTokens: 0 }, // 13B+ (no cap)
|
||||
]
|
||||
|
||||
export const SYSTEM_PROMPTS = {
|
||||
default: `
|
||||
Format all responses using markdown for better readability. Vanilla markdown or GitHub-flavored markdown is preferred.
|
||||
|
|
@ -81,9 +93,9 @@ IMPORTANT INSTRUCTIONS:
|
|||
1. If the user's question is directly related to the context above, use this information to provide accurate, detailed answers.
|
||||
2. Always cite or reference the context when using it (e.g., "According to the information available..." or "Based on the knowledge base...").
|
||||
3. If the context is only partially relevant, combine it with your general knowledge but be clear about what comes from the knowledge base.
|
||||
4. If the context is not relevant to the user's question, you can respond using your general knowledge without forcing the context into your answer.
|
||||
4. If the context is not relevant to the user's question, you can respond using your general knowledge without forcing the context into your answer. Do not mention the context if it's not relevant.
|
||||
5. Never fabricate information that isn't in the context or your training data.
|
||||
6. If you're unsure or the context doesn't contain enough information, acknowledge the limitations.
|
||||
6. If you're unsure or you don't have enough information to answer the user's question, acknowledge the limitations.
|
||||
|
||||
Format your response using markdown for readability.
|
||||
`,
|
||||
|
|
@ -111,6 +123,7 @@ Ensure that your suggestions are comma-seperated with no conjunctions like "and"
|
|||
Do not use line breaks, new lines, or extra spacing to separate the suggestions.
|
||||
Format: suggestion1, suggestion2, suggestion3
|
||||
`,
|
||||
title_generation: `You are a title generator. Given the start of a conversation, generate a concise, descriptive title under 50 characters. Return ONLY the title text with no quotes, punctuation wrapping, or extra formatting.`,
|
||||
query_rewrite: `
|
||||
You are a query rewriting assistant. Your task is to reformulate the user's latest question to include relevant context from the conversation history.
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
export default class extends BaseSchema {
|
||||
protected tableName = 'services'
|
||||
|
||||
async up() {
|
||||
this.schema.alterTable(this.tableName, (table) => {
|
||||
table.string('source_repo', 255).nullable()
|
||||
table.string('available_update_version', 50).nullable()
|
||||
table.timestamp('update_checked_at').nullable()
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.alterTable(this.tableName, (table) => {
|
||||
table.dropColumn('source_repo')
|
||||
table.dropColumn('available_update_version')
|
||||
table.dropColumn('update_checked_at')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
export default class extends BaseSchema {
|
||||
protected tableName = 'services'
|
||||
|
||||
async up() {
|
||||
this.defer(async (db) => {
|
||||
// Pin :latest images to specific versions
|
||||
await db
|
||||
.from(this.tableName)
|
||||
.where('container_image', 'ghcr.io/gchq/cyberchef:latest')
|
||||
.update({ container_image: 'ghcr.io/gchq/cyberchef:10.19.4' })
|
||||
|
||||
await db
|
||||
.from(this.tableName)
|
||||
.where('container_image', 'dullage/flatnotes:latest')
|
||||
.update({ container_image: 'dullage/flatnotes:v5.5.4' })
|
||||
|
||||
await db
|
||||
.from(this.tableName)
|
||||
.where('container_image', 'treehouses/kolibri:latest')
|
||||
.update({ container_image: 'treehouses/kolibri:0.12.8' })
|
||||
|
||||
// Populate source_repo for services whose images lack the OCI source label
|
||||
const sourceRepos: Record<string, string> = {
|
||||
nomad_kiwix_server: 'https://github.com/kiwix/kiwix-tools',
|
||||
nomad_ollama: 'https://github.com/ollama/ollama',
|
||||
nomad_qdrant: 'https://github.com/qdrant/qdrant',
|
||||
nomad_cyberchef: 'https://github.com/gchq/CyberChef',
|
||||
nomad_flatnotes: 'https://github.com/dullage/flatnotes',
|
||||
nomad_kolibri: 'https://github.com/learningequality/kolibri',
|
||||
}
|
||||
|
||||
for (const [serviceName, repoUrl] of Object.entries(sourceRepos)) {
|
||||
await db
|
||||
.from(this.tableName)
|
||||
.where('service_name', serviceName)
|
||||
.update({ source_repo: repoUrl })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.defer(async (db) => {
|
||||
await db
|
||||
.from(this.tableName)
|
||||
.where('container_image', 'ghcr.io/gchq/cyberchef:10.19.4')
|
||||
.update({ container_image: 'ghcr.io/gchq/cyberchef:latest' })
|
||||
|
||||
await db
|
||||
.from(this.tableName)
|
||||
.where('container_image', 'dullage/flatnotes:v5.5.4')
|
||||
.update({ container_image: 'dullage/flatnotes:latest' })
|
||||
|
||||
await db
|
||||
.from(this.tableName)
|
||||
.where('container_image', 'treehouses/kolibri:0.12.8')
|
||||
.update({ container_image: 'treehouses/kolibri:latest' })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -12,7 +12,7 @@ export default class ServiceSeeder extends BaseSeeder {
|
|||
)
|
||||
private static DEFAULT_SERVICES: Omit<
|
||||
ModelAttributes<Service>,
|
||||
'created_at' | 'updated_at' | 'metadata' | 'id'
|
||||
'created_at' | 'updated_at' | 'metadata' | 'id' | 'available_update_version' | 'update_checked_at'
|
||||
>[] = [
|
||||
{
|
||||
service_name: SERVICE_NAMES.KIWIX,
|
||||
|
|
@ -23,6 +23,7 @@ export default class ServiceSeeder extends BaseSeeder {
|
|||
'Offline access to Wikipedia, medical references, how-to guides, and encyclopedias',
|
||||
icon: 'IconBooks',
|
||||
container_image: 'ghcr.io/kiwix/kiwix-serve:3.8.1',
|
||||
source_repo: 'https://github.com/kiwix/kiwix-tools',
|
||||
container_command: '*.zim --address=all',
|
||||
container_config: JSON.stringify({
|
||||
HostConfig: {
|
||||
|
|
@ -46,6 +47,7 @@ export default class ServiceSeeder extends BaseSeeder {
|
|||
description: 'Vector database for storing and searching embeddings',
|
||||
icon: 'IconRobot',
|
||||
container_image: 'qdrant/qdrant:v1.16',
|
||||
source_repo: 'https://github.com/qdrant/qdrant',
|
||||
container_command: null,
|
||||
container_config: JSON.stringify({
|
||||
HostConfig: {
|
||||
|
|
@ -68,7 +70,8 @@ export default class ServiceSeeder extends BaseSeeder {
|
|||
display_order: 3,
|
||||
description: 'Local AI chat that runs entirely on your hardware - no internet required',
|
||||
icon: 'IconWand',
|
||||
container_image: 'ollama/ollama:0.15.2',
|
||||
container_image: 'ollama/ollama:0.18.1',
|
||||
source_repo: 'https://github.com/ollama/ollama',
|
||||
container_command: 'serve',
|
||||
container_config: JSON.stringify({
|
||||
HostConfig: {
|
||||
|
|
@ -91,7 +94,8 @@ export default class ServiceSeeder extends BaseSeeder {
|
|||
display_order: 11,
|
||||
description: 'Swiss Army knife for data encoding, encryption, and analysis',
|
||||
icon: 'IconChefHat',
|
||||
container_image: 'ghcr.io/gchq/cyberchef:latest',
|
||||
container_image: 'ghcr.io/gchq/cyberchef:10.22.1',
|
||||
source_repo: 'https://github.com/gchq/CyberChef',
|
||||
container_command: null,
|
||||
container_config: JSON.stringify({
|
||||
HostConfig: {
|
||||
|
|
@ -113,7 +117,8 @@ export default class ServiceSeeder extends BaseSeeder {
|
|||
display_order: 10,
|
||||
description: 'Simple note-taking app with local storage',
|
||||
icon: 'IconNotes',
|
||||
container_image: 'dullage/flatnotes:latest',
|
||||
container_image: 'dullage/flatnotes:v5.5.4',
|
||||
source_repo: 'https://github.com/dullage/flatnotes',
|
||||
container_command: null,
|
||||
container_config: JSON.stringify({
|
||||
HostConfig: {
|
||||
|
|
@ -137,7 +142,8 @@ export default class ServiceSeeder extends BaseSeeder {
|
|||
display_order: 2,
|
||||
description: 'Interactive learning platform with video courses and exercises',
|
||||
icon: 'IconSchool',
|
||||
container_image: 'treehouses/kolibri:latest',
|
||||
container_image: 'treehouses/kolibri:0.12.8',
|
||||
source_repo: 'https://github.com/learningequality/kolibri',
|
||||
container_command: null,
|
||||
container_config: JSON.stringify({
|
||||
HostConfig: {
|
||||
|
|
|
|||
|
|
@ -2,13 +2,16 @@
|
|||
|
||||
Project N.O.M.A.D. (Node for Offline Media, Archives, and Data; "Nomad" for short) is a project started in 2025 by Chris Sherwood of [Crosstalk Solutions, LLC](https://crosstalksolutions.com). The goal of the project is not to create just another utility for storing offline resources, but rather to allow users to run their own ultimate "survival computer".
|
||||
|
||||
While many similar offline survival computers are designed to be run on bare-minimum, lightweight hardware, Project N.O.M.A.D. is quite the opposite. To install and run the available AI tools, we highly encourage the use of a beefy, GPU-backed device to make the most of your install.
|
||||
While many similar offline survival computers are designed to be run on bare-minimum, lightweight hardware, Project N.O.M.A.D. is quite the opposite. To install and run the available AI tools, we highly encourage the use of a beefy, GPU-backed device to make the most of your install. See the [Hardware Guide](https://www.projectnomad.us/hardware) for detailed build recommendations at three price points.
|
||||
|
||||
Since its initial release, NOMAD has grown to include built-in AI chat with a Knowledge Base for document-aware responses, a System Benchmark with a community leaderboard, curated content collections with tiered options, and an Easy Setup Wizard to get new users up and running quickly.
|
||||
|
||||
Project N.O.M.A.D. is open source, released under the [Apache License 2.0](https://github.com/Crosstalk-Solutions/project-nomad/blob/main/LICENSE).
|
||||
|
||||
## Links
|
||||
|
||||
- **Website:** [www.projectnomad.us](https://www.projectnomad.us)
|
||||
- **Hardware Guide:** [www.projectnomad.us/hardware](https://www.projectnomad.us/hardware)
|
||||
- **Discord:** [Join the Community](https://discord.com/invite/crosstalksolutions)
|
||||
- **GitHub:** [Crosstalk-Solutions/project-nomad](https://github.com/Crosstalk-Solutions/project-nomad)
|
||||
- **Benchmark Leaderboard:** [benchmark.projectnomad.us](https://benchmark.projectnomad.us)
|
||||
|
|
|
|||
|
|
@ -13,11 +13,13 @@ No — that's the whole point. Once your content is downloaded, everything works
|
|||
|
||||
### What hardware do I need?
|
||||
N.O.M.A.D. is designed for capable hardware, especially if you want to use the AI features. Recommended:
|
||||
- Modern multi-core CPU
|
||||
- Modern multi-core CPU (AMD Ryzen 7 with Radeon graphics is the community sweet spot)
|
||||
- 16GB+ RAM (32GB+ for best AI performance)
|
||||
- SSD storage (size depends on content — 500GB minimum, 2TB+ recommended)
|
||||
- SSD storage (size depends on content — 500GB minimum, 1TB+ recommended)
|
||||
- NVIDIA or AMD GPU recommended for faster AI responses
|
||||
|
||||
**For detailed build recommendations at three price points ($150–$1,000+), see the [Hardware Guide](https://www.projectnomad.us/hardware).**
|
||||
|
||||
### How much storage do I need?
|
||||
It depends on what you download:
|
||||
- Full Wikipedia: ~95GB
|
||||
|
|
@ -79,6 +81,8 @@ The AI must be installed first — enable it during Easy Setup or install it fro
|
|||
3. Documents are processed and indexed automatically
|
||||
4. Ask questions in AI Chat — the AI will reference your uploaded documents when relevant
|
||||
|
||||
You can also remove documents from the Knowledge Base when they're no longer needed.
|
||||
|
||||
NOMAD documentation is automatically added to the Knowledge Base when the AI Assistant is installed.
|
||||
|
||||
### What is the System Benchmark?
|
||||
|
|
@ -86,6 +90,9 @@ The System Benchmark tests your hardware performance and generates a NOMAD Score
|
|||
|
||||
Go to **[System Benchmark →](/settings/benchmark)** to run one.
|
||||
|
||||
### What is the Early Access Channel?
|
||||
The Early Access Channel lets you opt in to receive release candidate builds with the latest features and improvements before they hit stable releases. You can enable or disable it from **Settings → Check for Updates**. Early access builds may contain bugs — if you prefer stability, stay on the stable channel.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
|
@ -137,6 +144,10 @@ When you add or swap a GPU, N.O.M.A.D. needs to reconfigure the AI container to
|
|||
|
||||
Force Reinstall recreates the AI container with GPU support enabled. Without this step, the AI continues to run on CPU only.
|
||||
|
||||
### I see a "GPU passthrough not working" warning
|
||||
|
||||
N.O.M.A.D. checks whether your GPU is actually accessible inside the AI container. If a GPU is detected on the host but isn't working inside the container, you'll see a warning banner on the System Information and AI Settings pages. Click the **"Fix: Reinstall AI Assistant"** button to recreate the container with proper GPU access. This preserves your downloaded AI models.
|
||||
|
||||
### AI Chat not available
|
||||
|
||||
The AI Chat page requires the AI Assistant to be installed first:
|
||||
|
|
@ -246,7 +257,7 @@ sudo bash /opt/project-nomad/update_nomad.sh
|
|||
|
||||
**Uninstall N.O.M.A.D.:**
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/master/install/uninstall_nomad.sh -o uninstall_nomad.sh
|
||||
curl -fsSL https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/install/uninstall_nomad.sh -o uninstall_nomad.sh
|
||||
sudo bash uninstall_nomad.sh
|
||||
```
|
||||
*Warning: This cannot be undone. All data will be deleted.*
|
||||
|
|
|
|||
|
|
@ -103,6 +103,7 @@ The Knowledge Base lets you upload documents so the AI can reference them when a
|
|||
2. Upload your documents (PDFs, text files, etc.)
|
||||
3. Documents are processed and indexed automatically
|
||||
4. Ask questions in AI Chat — the AI will reference your uploaded documents when relevant
|
||||
5. Remove documents you no longer need — they'll be deleted from the index and local storage
|
||||
|
||||
**Use cases:**
|
||||
- Upload emergency plans for quick reference during a crisis
|
||||
|
|
@ -183,6 +184,8 @@ While you have internet, periodically check for updates:
|
|||
|
||||
Content updates (Wikipedia, maps, etc.) can be managed separately from software updates.
|
||||
|
||||
**Early Access Channel:** Want the latest features before they hit stable? Enable the Early Access Channel from the Check for Updates page to receive release candidate builds. You can switch back to stable anytime.
|
||||
|
||||
### Monitoring System Health
|
||||
|
||||
Check on your server anytime:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,100 @@
|
|||
# Release Notes
|
||||
|
||||
## Version 1.30.3 - March 25, 2026
|
||||
|
||||
### Features
|
||||
|
||||
### Bug Fixes
|
||||
- **Benchmark**: Fixed an issue where CPU and Disk Write scores could be displayed as 0 if the measured values was less than half of the reference mark. Thanks @bortlesboat for the fix!
|
||||
- **Content Manager**: Fixed a missing API client method that was causing ZIM file deletions to fail. Thanks @LuisMIguelFurlanettoSousa for the fix!
|
||||
- **Install**: Fixed an issue where the install script could incorrectly report the Docker NVIDIA runtime as missing. Thanks @brenex for the fix!
|
||||
- **Support the Project**: Fixed a broken link to Rogue Support. Thanks @chriscrosstalk for the fix!
|
||||
|
||||
### Improvements
|
||||
- **AI Assistant**: Improved error reporting and handling for model downloads. Thanks @chriscrosstalk for the contribution!
|
||||
- **AI Assistant**: Bumped the default version of Ollama installed to v0.18.1 to take advantage of the latest performance improvements and bug fixes.
|
||||
- **Apps**: Improved error reporting and handling for service installation failures. Thanks @trek-e for the contribution!
|
||||
- **Collections**: Updated various curated collection links to their latest versions. Thanks @builder555 for the contribution!
|
||||
- **Cyberchef**: Bumped the default version of CyberChef installed to v10.22.1 to take advantage of the latest features and bug fixes.
|
||||
- **Docs**: Added a link to the step-by-step installation guide and video tutorial. Thanks @chriscrosstalk for the contribution!
|
||||
- **Install**: Increased the retries limit for the MySQL service in Docker Compose to improve stability during installation on systems with slower performance. Thanks @dx4956 for the contribution!
|
||||
- **Install**: Fixed an issue where stale data could cause credentials mismatch in MySQL on reinstall. Thanks @chriscrosstalk for the fix!
|
||||
|
||||
## Version 1.30.0 - March 20, 2026
|
||||
|
||||
### Features
|
||||
- **Night Ops**: Added our most requested feature — a dark mode theme for the Command Center interface! Activate it from the footer and enjoy the sleek new look during your late-night missions. Thanks @chriscrosstalk for the contribution!
|
||||
- **Debug Info**: Added a new "Debug Info" modal accessible from the footer that provides detailed system and application information for troubleshooting and support. Thanks @chriscrosstalk for the contribution!
|
||||
- **Support the Project**: Added a new "Support the Project" page in settings with links to community resources, donation options, and ways to contribute.
|
||||
- **Install**: The main Nomad image is now fully self-contained and directly usable with Docker Compose, allowing for more flexible and customizable installations without relying on external scripts. The image remains fully backwards compatible with existing installations, and the install script has been updated to reflect the simpler deployment process.
|
||||
|
||||
### Bug Fixes
|
||||
- **Settings**: Storage usage display now prefers real block devices over tempfs. Thanks @Bortlesboat for the fix!
|
||||
- **Settings**: Fixed an issue where device matching and mount entry deduplication logic could cause incorrect storage usage reporting and missing devices in storage displays.
|
||||
- **Maps**: The Maps page now respects the request protocol (http vs https) to ensure map tiles load correctly. Thanks @davidgross for the bug report!
|
||||
- **Knowledge Base**: Fixed an issue where file embedding jobs could cause a retry storm if the Ollama service was unavailable. Thanks @skyam25 for the bug report!
|
||||
- **Curated Collections**: Fixed some broken links in the curated collections definitions (maps and ZIM files) that were causing some resources to fail to download.
|
||||
- **Easy Setup**: Fixed an issue where the "Start Here" badge would persist even after visiting the Easy Setup Wizard for the first time. Thanks @chriscrosstalk for the fix!
|
||||
- **UI**: Fixed an issue where the loading spinner could look strange in certain use cases.
|
||||
- **System Updates**: Fixed an issue where the update banner would persist even after the system was updated successfully. Thanks @chriscrosstalk for the fix!
|
||||
- **Performance**: Various small memory leak fixes and performance improvements across the UI to ensure a smoother experience.
|
||||
|
||||
### Improvements
|
||||
- **Ollama**: Improved GPU detection logic to ensure the latest GPU config is always passed to the Ollama container on update
|
||||
- **Ollama**: The detected GPU type is now persisted in the database for more reliable configuration and troubleshooting across updates and restarts. Thanks @chriscrosstalk for the contribution!
|
||||
- **Downloads**: Users can now dismiss failed download notifications to reduce clutter in the UI. Thanks @chriscrosstalk for the contribution!
|
||||
- **Logging**: Changed the default log level to "info" to reduce noise and focus on important messages. Thanks @traxeon for the suggestion!
|
||||
- **Logging**: Nomad's internal logger now creates it's own log directory on startup if it doesn't already exist to prevent errors on fresh installs where the logs directory hasn't been created yet.
|
||||
- **Dozzle**: Dozzle shell access and container actions are now disabled by default. Thanks @traxeon for the recommendation!
|
||||
- **MySQL & Redis**: Removed port exposure to host by default for improved security. Ports can still be exposed manually if needed. Thanks @traxeon for the recommendation!
|
||||
- **Dependencies**: Various dependency updates to close security vulnerabilities and improve stability
|
||||
- **Utility Scripts**: Added a check for the expected Docker Compose version (v2) in all utility scripts to provide clearer error messages and guidance if the environment is not set up correctly.
|
||||
- **Utility Scripts**: Added an additional warning to the installation script to inform about potential overwriting of existing customized configurations and the importance of backing up data before running the installation script again.
|
||||
- **Documentation**: Updated installation instructions to reflect the new option for manual deployment via Docker Compose without the install script.
|
||||
|
||||
|
||||
## Version 1.29.0 - March 11, 2026
|
||||
|
||||
### Features
|
||||
- **AI Assistant**: Added improved user guidance for troubleshooting GPU pass-through issues
|
||||
- **AI Assistant**: The last used model is now automatically selected when a new chat is started
|
||||
- **Settings**: Nomad now automatically performs nightly checks for available app updates, and users can select and apply updates from the Apps page in Settings
|
||||
|
||||
### Bug Fixes
|
||||
- **Settings**: Fixed an issue where the AI Assistant settings page would be shown in navigation even if the AI Assistant was not installed, thus causing 404 errors when clicked
|
||||
- **Security**: Path traversal and SSRF mitigations
|
||||
- **AI Assistant**: Fixed an issue that was causing intermittent failures saving chat session titles
|
||||
|
||||
### Improvements
|
||||
- **AI Assistant**: Extensive performance improvements and improved RAG intelligence/context usage
|
||||
|
||||
## Version 1.28.0 - March 5, 2026
|
||||
|
||||
### Features
|
||||
- **RAG**: Added support for viewing active embedding jobs in the processing queue and improved job progress tracking with more granular status updates
|
||||
- **RAG**: Added support for removing documents from the knowledge base (deletion from Qdrant and local storage)
|
||||
|
||||
### Bug Fixes
|
||||
- **Install**: Fixed broken url's in install script and updated to prompt for Apache 2.0 license acceptance
|
||||
- **Docs**: Updated legal notices to reflect Apache 2.0 license and added Qdrant attribution
|
||||
- **Dependencies**: Various minor dependency updates to close security vulnerabilities
|
||||
|
||||
### Improvements
|
||||
- **License**: Added Apache 2.0 license file to repository for clarity and legal compliance
|
||||
|
||||
## Version 1.27.0 - March 4, 2026
|
||||
|
||||
### Features
|
||||
- **Settings**: Added pagination support for Ollama model list
|
||||
- **Early Access Channel**: Allows users to opt in to receive early access builds with the latest features and improvements before they hit stable releases
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
### Improvements
|
||||
- **AI Assistant**: Improved chat performance by optimizing query rewriting and response streaming logic
|
||||
- **CI/CD**: Updated release workflows to support release candidate versions
|
||||
- **KV Store**: Improved type safety in KV store implementation
|
||||
|
||||
## Version 1.26.0 - February 19, 2026
|
||||
|
||||
### Features
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { generateUUID } from '~/lib/util'
|
|||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
||||
import NotificationsProvider from '~/providers/NotificationProvider'
|
||||
import { ThemeProvider } from '~/providers/ThemeProvider'
|
||||
import { UsePageProps } from '../../types/system'
|
||||
|
||||
const appName = import.meta.env.VITE_APP_NAME || 'Project N.O.M.A.D.'
|
||||
|
|
@ -38,14 +39,16 @@ createInertiaApp({
|
|||
const showDevtools = ['development', 'staging'].includes(environment)
|
||||
createRoot(el).render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TransmitProvider baseUrl={window.location.origin} enableLogging={true}>
|
||||
<NotificationsProvider>
|
||||
<ModalsProvider>
|
||||
<App {...props} />
|
||||
{showDevtools && <ReactQueryDevtools initialIsOpen={false} buttonPosition='bottom-left' />}
|
||||
</ModalsProvider>
|
||||
</NotificationsProvider>
|
||||
</TransmitProvider>
|
||||
<ThemeProvider>
|
||||
<TransmitProvider baseUrl={window.location.origin} enableLogging={environment === 'development'}>
|
||||
<NotificationsProvider>
|
||||
<ModalsProvider>
|
||||
<App {...props} />
|
||||
{showDevtools && <ReactQueryDevtools initialIsOpen={false} buttonPosition='bottom-left' />}
|
||||
</ModalsProvider>
|
||||
</NotificationsProvider>
|
||||
</TransmitProvider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import useDownloads, { useDownloadsProps } from '~/hooks/useDownloads'
|
|||
import HorizontalBarChart from './HorizontalBarChart'
|
||||
import { extractFileName } from '~/lib/util'
|
||||
import StyledSectionHeader from './StyledSectionHeader'
|
||||
import { IconAlertTriangle, IconX } from '@tabler/icons-react'
|
||||
import api from '~/lib/api'
|
||||
|
||||
interface ActiveDownloadProps {
|
||||
filetype?: useDownloadsProps['filetype']
|
||||
|
|
@ -9,7 +11,12 @@ interface ActiveDownloadProps {
|
|||
}
|
||||
|
||||
const ActiveDownloads = ({ filetype, withHeader = false }: ActiveDownloadProps) => {
|
||||
const { data: downloads } = useDownloads({ filetype })
|
||||
const { data: downloads, invalidate } = useDownloads({ filetype })
|
||||
|
||||
const handleDismiss = async (jobId: string) => {
|
||||
await api.removeDownloadJob(jobId)
|
||||
invalidate()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -17,22 +24,50 @@ const ActiveDownloads = ({ filetype, withHeader = false }: ActiveDownloadProps)
|
|||
<div className="space-y-4">
|
||||
{downloads && downloads.length > 0 ? (
|
||||
downloads.map((download) => (
|
||||
<div className="bg-desert-white rounded-lg p-4 border border-desert-stone-light shadow-sm hover:shadow-lg transition-shadow">
|
||||
<HorizontalBarChart
|
||||
items={[
|
||||
{
|
||||
label: extractFileName(download.filepath) || download.url,
|
||||
value: download.progress,
|
||||
total: '100%',
|
||||
used: `${download.progress}%`,
|
||||
type: download.filetype,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<div
|
||||
key={download.jobId}
|
||||
className={`bg-desert-white rounded-lg p-4 border shadow-sm hover:shadow-lg transition-shadow ${
|
||||
download.status === 'failed'
|
||||
? 'border-red-300'
|
||||
: 'border-desert-stone-light'
|
||||
}`}
|
||||
>
|
||||
{download.status === 'failed' ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<IconAlertTriangle className="w-5 h-5 text-red-500 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 truncate">
|
||||
{extractFileName(download.filepath) || download.url}
|
||||
</p>
|
||||
<p className="text-xs text-red-600 mt-0.5">
|
||||
Download failed{download.failedReason ? `: ${download.failedReason}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDismiss(download.jobId)}
|
||||
className="flex-shrink-0 p-1 rounded hover:bg-red-100 transition-colors"
|
||||
title="Dismiss failed download"
|
||||
>
|
||||
<IconX className="w-4 h-4 text-red-400 hover:text-red-600" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<HorizontalBarChart
|
||||
items={[
|
||||
{
|
||||
label: extractFileName(download.filepath) || download.url,
|
||||
value: download.progress,
|
||||
total: '100%',
|
||||
used: `${download.progress}%`,
|
||||
type: download.filetype,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-gray-500">No active downloads</p>
|
||||
<p className="text-text-muted">No active downloads</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
|
|
|||
45
admin/inertia/components/ActiveEmbedJobs.tsx
Normal file
45
admin/inertia/components/ActiveEmbedJobs.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import useEmbedJobs from '~/hooks/useEmbedJobs'
|
||||
import HorizontalBarChart from './HorizontalBarChart'
|
||||
import StyledSectionHeader from './StyledSectionHeader'
|
||||
|
||||
interface ActiveEmbedJobsProps {
|
||||
withHeader?: boolean
|
||||
}
|
||||
|
||||
const ActiveEmbedJobs = ({ withHeader = false }: ActiveEmbedJobsProps) => {
|
||||
const { data: jobs } = useEmbedJobs()
|
||||
|
||||
return (
|
||||
<>
|
||||
{withHeader && (
|
||||
<StyledSectionHeader title="Processing Queue" className="mt-12 mb-4" />
|
||||
)}
|
||||
<div className="space-y-4">
|
||||
{jobs && jobs.length > 0 ? (
|
||||
jobs.map((job) => (
|
||||
<div
|
||||
key={job.jobId}
|
||||
className="bg-desert-white rounded-lg p-4 border border-desert-stone-light shadow-sm hover:shadow-lg transition-shadow"
|
||||
>
|
||||
<HorizontalBarChart
|
||||
items={[
|
||||
{
|
||||
label: job.fileName,
|
||||
value: job.progress,
|
||||
total: '100%',
|
||||
used: `${job.progress}%`,
|
||||
type: job.status,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-text-muted">No files are currently being processed</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ActiveEmbedJobs
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import useOllamaModelDownloads from '~/hooks/useOllamaModelDownloads'
|
||||
import HorizontalBarChart from './HorizontalBarChart'
|
||||
import StyledSectionHeader from './StyledSectionHeader'
|
||||
import { IconAlertTriangle } from '@tabler/icons-react'
|
||||
|
||||
interface ActiveModelDownloadsProps {
|
||||
withHeader?: boolean
|
||||
|
|
@ -17,23 +18,35 @@ const ActiveModelDownloads = ({ withHeader = false }: ActiveModelDownloadsProps)
|
|||
downloads.map((download) => (
|
||||
<div
|
||||
key={download.model}
|
||||
className="bg-desert-white rounded-lg p-4 border border-desert-stone-light shadow-sm hover:shadow-lg transition-shadow"
|
||||
className={`bg-desert-white rounded-lg p-4 border shadow-sm hover:shadow-lg transition-shadow ${
|
||||
download.error ? 'border-red-400' : 'border-desert-stone-light'
|
||||
}`}
|
||||
>
|
||||
<HorizontalBarChart
|
||||
items={[
|
||||
{
|
||||
label: download.model,
|
||||
value: download.percent,
|
||||
total: '100%',
|
||||
used: `${download.percent.toFixed(1)}%`,
|
||||
type: 'ollama-model',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{download.error ? (
|
||||
<div className="flex items-start gap-3">
|
||||
<IconAlertTriangle className="text-red-500 flex-shrink-0 mt-0.5" size={20} />
|
||||
<div>
|
||||
<p className="font-medium text-text-primary">{download.model}</p>
|
||||
<p className="text-sm text-red-600 mt-1">{download.error}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<HorizontalBarChart
|
||||
items={[
|
||||
{
|
||||
label: download.model,
|
||||
value: download.percent,
|
||||
total: '100%',
|
||||
used: `${download.percent.toFixed(1)}%`,
|
||||
type: 'ollama-model',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-gray-500">No active model downloads</p>
|
||||
<p className="text-text-muted">No active model downloads</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ export default function Alert({
|
|||
}
|
||||
|
||||
const getIconColor = () => {
|
||||
if (variant === 'solid') return 'text-desert-white'
|
||||
if (variant === 'solid') return 'text-white'
|
||||
switch (type) {
|
||||
case 'warning':
|
||||
return 'text-desert-orange'
|
||||
|
|
@ -81,15 +81,15 @@ export default function Alert({
|
|||
case 'solid':
|
||||
variantStyles.push(
|
||||
type === 'warning'
|
||||
? 'bg-desert-orange text-desert-white border border-desert-orange-dark'
|
||||
? 'bg-desert-orange text-white border border-desert-orange-dark'
|
||||
: type === 'error'
|
||||
? 'bg-desert-red text-desert-white border border-desert-red-dark'
|
||||
? 'bg-desert-red text-white border border-desert-red-dark'
|
||||
: type === 'success'
|
||||
? 'bg-desert-olive text-desert-white border border-desert-olive-dark'
|
||||
? 'bg-desert-olive text-white border border-desert-olive-dark'
|
||||
: type === 'info'
|
||||
? 'bg-desert-green text-desert-white border border-desert-green-dark'
|
||||
? 'bg-desert-green text-white border border-desert-green-dark'
|
||||
: type === 'info-inverted'
|
||||
? 'bg-desert-tan text-desert-white border border-desert-tan-dark'
|
||||
? 'bg-desert-tan text-white border border-desert-tan-dark'
|
||||
: ''
|
||||
)
|
||||
return classNames(baseStyles, 'shadow-lg', ...variantStyles)
|
||||
|
|
@ -112,7 +112,7 @@ export default function Alert({
|
|||
}
|
||||
|
||||
const getTitleColor = () => {
|
||||
if (variant === 'solid') return 'text-desert-white'
|
||||
if (variant === 'solid') return 'text-white'
|
||||
|
||||
switch (type) {
|
||||
case 'warning':
|
||||
|
|
@ -131,7 +131,7 @@ export default function Alert({
|
|||
}
|
||||
|
||||
const getMessageColor = () => {
|
||||
if (variant === 'solid') return 'text-desert-white text-opacity-90'
|
||||
if (variant === 'solid') return 'text-white text-opacity-90'
|
||||
|
||||
switch (type) {
|
||||
case 'warning':
|
||||
|
|
@ -149,7 +149,7 @@ export default function Alert({
|
|||
|
||||
const getCloseButtonStyles = () => {
|
||||
if (variant === 'solid') {
|
||||
return 'text-desert-white hover:text-desert-white hover:bg-black hover:bg-opacity-20'
|
||||
return 'text-white hover:text-white hover:bg-black hover:bg-opacity-20'
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
|
|
|
|||
|
|
@ -9,18 +9,18 @@ interface BouncingDotsProps {
|
|||
export default function BouncingDots({ text, containerClassName, textClassName }: BouncingDotsProps) {
|
||||
return (
|
||||
<div className={clsx("flex items-center justify-center gap-2", containerClassName)}>
|
||||
<span className={clsx("text-gray-600", textClassName)}>{text}</span>
|
||||
<span className={clsx("text-text-secondary", textClassName)}>{text}</span>
|
||||
<span className="flex gap-1 mt-1">
|
||||
<span
|
||||
className="w-1.5 h-1.5 bg-gray-600 rounded-full animate-bounce"
|
||||
className="w-1.5 h-1.5 bg-text-secondary rounded-full animate-bounce"
|
||||
style={{ animationDelay: '0ms' }}
|
||||
/>
|
||||
<span
|
||||
className="w-1.5 h-1.5 bg-gray-600 rounded-full animate-bounce"
|
||||
className="w-1.5 h-1.5 bg-text-secondary rounded-full animate-bounce"
|
||||
style={{ animationDelay: '150ms' }}
|
||||
/>
|
||||
<span
|
||||
className="w-1.5 h-1.5 bg-gray-600 rounded-full animate-bounce"
|
||||
className="w-1.5 h-1.5 bg-text-secondary rounded-full animate-bounce"
|
||||
style={{ animationDelay: '300ms' }}
|
||||
/>
|
||||
</span>
|
||||
|
|
|
|||
103
admin/inertia/components/DebugInfoModal.tsx
Normal file
103
admin/inertia/components/DebugInfoModal.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { IconBug, IconCopy, IconCheck } from '@tabler/icons-react'
|
||||
import StyledModal from './StyledModal'
|
||||
import api from '~/lib/api'
|
||||
|
||||
interface DebugInfoModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function DebugInfoModal({ open, onClose }: DebugInfoModalProps) {
|
||||
const [debugText, setDebugText] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
setLoading(true)
|
||||
setCopied(false)
|
||||
|
||||
api.getDebugInfo().then((text) => {
|
||||
if (text) {
|
||||
const browserLine = `Browser: ${navigator.userAgent}`
|
||||
setDebugText(text + '\n' + browserLine)
|
||||
} else {
|
||||
setDebugText('Failed to load debug info. Please try again.')
|
||||
}
|
||||
setLoading(false)
|
||||
}).catch(() => {
|
||||
setDebugText('Failed to load debug info. Please try again.')
|
||||
setLoading(false)
|
||||
})
|
||||
}, [open])
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(debugText)
|
||||
} catch {
|
||||
// Fallback for older browsers
|
||||
const textarea = document.querySelector<HTMLTextAreaElement>('#debug-info-text')
|
||||
if (textarea) {
|
||||
textarea.select()
|
||||
document.execCommand('copy')
|
||||
}
|
||||
}
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledModal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Debug Info"
|
||||
icon={<IconBug className="size-8 text-desert-green" />}
|
||||
cancelText="Close"
|
||||
onCancel={onClose}
|
||||
>
|
||||
<p className="text-sm text-gray-500 mb-3 text-left">
|
||||
This is non-sensitive system info you can share when reporting issues.
|
||||
No passwords, IPs, or API keys are included.
|
||||
</p>
|
||||
|
||||
<textarea
|
||||
id="debug-info-text"
|
||||
readOnly
|
||||
value={loading ? 'Loading...' : debugText}
|
||||
rows={18}
|
||||
className="w-full font-mono text-xs text-black bg-gray-50 border border-gray-200 rounded-md p-3 resize-none focus:outline-none text-left"
|
||||
/>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
disabled={loading}
|
||||
className="inline-flex items-center gap-1.5 rounded-md bg-desert-green px-3 py-1.5 text-sm font-semibold text-white hover:bg-desert-green-dark transition-colors disabled:opacity-50"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<IconCheck className="size-4" />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<IconCopy className="size-4" />
|
||||
Copy to Clipboard
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<a
|
||||
href="https://github.com/Crosstalk-Solutions/project-nomad/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-desert-green hover:underline"
|
||||
>
|
||||
Open a GitHub Issue
|
||||
</a>
|
||||
</div>
|
||||
</StyledModal>
|
||||
)
|
||||
}
|
||||
|
|
@ -63,7 +63,7 @@ const DownloadURLModal: React.FC<DownloadURLModalProps> = ({
|
|||
large
|
||||
>
|
||||
<div className="flex flex-col pb-4">
|
||||
<p className="text-gray-700 mb-8">
|
||||
<p className="text-text-secondary mb-8">
|
||||
Enter the URL of the map region file you wish to download. The URL must be publicly
|
||||
reachable and end with .pmtiles. A preflight check will be run to verify the file's
|
||||
availability, type, and approximate size.
|
||||
|
|
@ -76,11 +76,11 @@ const DownloadURLModal: React.FC<DownloadURLModalProps> = ({
|
|||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
/>
|
||||
<div className="min-h-24 max-h-96 overflow-y-auto bg-gray-50 p-4 rounded border border-gray-300 text-left">
|
||||
<div className="min-h-24 max-h-96 overflow-y-auto bg-surface-secondary p-4 rounded border border-border-default text-left">
|
||||
{messages.map((message, idx) => (
|
||||
<p
|
||||
key={idx}
|
||||
className="text-sm text-gray-900 font-mono leading-relaxed break-words mb-3"
|
||||
className="text-sm text-text-primary font-mono leading-relaxed break-words mb-3"
|
||||
>
|
||||
{message}
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -1,15 +1,31 @@
|
|||
import { useState } from 'react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import { UsePageProps } from '../../types/system'
|
||||
import ThemeToggle from '~/components/ThemeToggle'
|
||||
import { IconBug } from '@tabler/icons-react'
|
||||
import DebugInfoModal from './DebugInfoModal'
|
||||
|
||||
export default function Footer() {
|
||||
const { appVersion } = usePage().props as unknown as UsePageProps
|
||||
const [debugModalOpen, setDebugModalOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<footer className="">
|
||||
<div className="flex justify-center border-t border-gray-900/10 py-4">
|
||||
<p className="text-sm/6 text-gray-600">
|
||||
<footer>
|
||||
<div className="flex items-center justify-center gap-3 border-t border-border-subtle py-4">
|
||||
<p className="text-sm/6 text-text-secondary">
|
||||
Project N.O.M.A.D. Command Center v{appVersion}
|
||||
</p>
|
||||
<span className="text-gray-300">|</span>
|
||||
<button
|
||||
onClick={() => setDebugModalOpen(true)}
|
||||
className="text-sm/6 text-gray-500 hover:text-desert-green flex items-center gap-1 cursor-pointer"
|
||||
>
|
||||
<IconBug className="size-3.5" />
|
||||
Debug Info
|
||||
</button>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<DebugInfoModal open={debugModalOpen} onClose={() => setDebugModalOpen(false)} />
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ export default function HorizontalBarChart({
|
|||
className={classNames(
|
||||
'absolute top-1/2 -translate-y-1/2 font-bold text-sm',
|
||||
item.value > 15
|
||||
? 'left-3 text-desert-white drop-shadow-md'
|
||||
? 'left-3 text-white drop-shadow-md'
|
||||
: 'right-3 text-desert-green'
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { IconCircleCheck } from '@tabler/icons-react'
|
||||
import { IconCircleCheck, IconCircleX } from '@tabler/icons-react'
|
||||
import classNames from '~/lib/classNames'
|
||||
|
||||
export type InstallActivityFeedProps = {
|
||||
|
|
@ -16,6 +16,12 @@ export type InstallActivityFeedProps = {
|
|||
| 'started'
|
||||
| 'finalizing'
|
||||
| 'completed'
|
||||
| 'update-pulling'
|
||||
| 'update-stopping'
|
||||
| 'update-creating'
|
||||
| 'update-starting'
|
||||
| 'update-complete'
|
||||
| 'update-rollback'
|
||||
timestamp: string
|
||||
message: string
|
||||
}>
|
||||
|
|
@ -25,8 +31,8 @@ export type InstallActivityFeedProps = {
|
|||
|
||||
const InstallActivityFeed: React.FC<InstallActivityFeedProps> = ({ activity, className, withHeader = false }) => {
|
||||
return (
|
||||
<div className={classNames('bg-white shadow-sm rounded-lg p-6', className)}>
|
||||
{withHeader && <h2 className="text-lg font-semibold text-gray-900">Installation Activity</h2>}
|
||||
<div className={classNames('bg-surface-primary shadow-sm rounded-lg p-6', className)}>
|
||||
{withHeader && <h2 className="text-lg font-semibold text-text-primary">Installation Activity</h2>}
|
||||
<ul role="list" className={classNames("space-y-6 text-desert-green", withHeader ? 'mt-6' : '')}>
|
||||
{activity.map((activityItem, activityItemIdx) => (
|
||||
<li key={activityItem.timestamp} className="relative flex gap-x-4">
|
||||
|
|
@ -36,23 +42,25 @@ const InstallActivityFeed: React.FC<InstallActivityFeedProps> = ({ activity, cla
|
|||
'absolute left-0 top-0 flex w-6 justify-center'
|
||||
)}
|
||||
>
|
||||
<div className="w-px bg-gray-200" />
|
||||
<div className="w-px bg-border-subtle" />
|
||||
</div>
|
||||
<>
|
||||
<div className="relative flex size-6 flex-none items-center justify-center bg-transparent">
|
||||
{activityItem.type === 'completed' ? (
|
||||
{activityItem.type === 'completed' || activityItem.type === 'update-complete' ? (
|
||||
<IconCircleCheck aria-hidden="true" className="size-6 text-indigo-600" />
|
||||
) : activityItem.type === 'update-rollback' ? (
|
||||
<IconCircleX aria-hidden="true" className="size-6 text-red-500" />
|
||||
) : (
|
||||
<div className="size-1.5 rounded-full bg-gray-100 ring-1 ring-gray-300" />
|
||||
<div className="size-1.5 rounded-full bg-surface-secondary ring-1 ring-border-default" />
|
||||
)}
|
||||
</div>
|
||||
<p className="flex-auto py-0.5 text-xs/5 text-gray-500">
|
||||
<span className="font-semibold text-gray-900">{activityItem.service_name}</span> -{' '}
|
||||
<p className="flex-auto py-0.5 text-xs/5 text-text-muted">
|
||||
<span className="font-semibold text-text-primary">{activityItem.service_name}</span> -{' '}
|
||||
{activityItem.type.charAt(0).toUpperCase() + activityItem.type.slice(1)}
|
||||
</p>
|
||||
<time
|
||||
dateTime={activityItem.timestamp}
|
||||
className="flex-none py-0.5 text-xs/5 text-gray-500"
|
||||
className="flex-none py-0.5 text-xs/5 text-text-muted"
|
||||
>
|
||||
{activityItem.timestamp}
|
||||
</time>
|
||||
|
|
|
|||
|
|
@ -15,12 +15,12 @@ const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
|
|||
}) => {
|
||||
if (!fullscreen) {
|
||||
return (
|
||||
<div className={`flex flex-col items-center justify-center ${className}`}>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<div
|
||||
className={`w-8 h-8 border-[3px] ${light ? 'border-white' : 'border-slate-400'} border-t-transparent rounded-full animate-spin`}
|
||||
className={`w-8 h-8 border-[3px] ${light ? 'border-white' : 'border-text-muted'} border-t-transparent rounded-full animate-spin ${className || ''}`}
|
||||
></div>
|
||||
{!iconOnly && (
|
||||
<div className={light ? 'text-white mt-2' : 'text-slate-800 mt-2'}>
|
||||
<div className={light ? 'text-white mt-2' : 'text-text-primary mt-2'}>
|
||||
{text || 'Loading...'}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -9,14 +9,14 @@ const ProgressBar = ({ progress, speed }: { progress: number; speed?: string })
|
|||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="relative w-full h-2 bg-gray-200 rounded">
|
||||
<div className="relative w-full h-2 bg-border-subtle rounded">
|
||||
<div
|
||||
className="absolute top-0 left-0 h-full bg-desert-green rounded"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
{speed && (
|
||||
<div className="mt-1 text-sm text-gray-500">
|
||||
<div className="mt-1 text-sm text-text-muted">
|
||||
Est. Speed: {speed}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ export default function StorageProjectionBar({
|
|||
className={classNames(
|
||||
'absolute top-1/2 -translate-y-1/2 font-bold text-sm',
|
||||
projectedTotalPercent > 15
|
||||
? 'left-3 text-desert-white drop-shadow-md'
|
||||
? 'left-3 text-white drop-shadow-md'
|
||||
: 'right-3 text-desert-green'
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ const StyledButton: React.FC<StyledButtonProps> = ({
|
|||
size = 'md',
|
||||
loading = false,
|
||||
fullWidth = false,
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
const isDisabled = useMemo(() => {
|
||||
|
|
@ -55,9 +56,9 @@ const StyledButton: React.FC<StyledButtonProps> = ({
|
|||
switch (variant) {
|
||||
case 'primary':
|
||||
return clsx(
|
||||
'bg-desert-green text-desert-white',
|
||||
'hover:bg-desert-green-dark hover:shadow-lg',
|
||||
'active:bg-desert-green-darker',
|
||||
'bg-desert-green text-white',
|
||||
'hover:bg-btn-green-hover hover:shadow-lg',
|
||||
'active:bg-btn-green-active',
|
||||
'disabled:bg-desert-green-light disabled:text-desert-stone-light',
|
||||
baseTransition,
|
||||
baseHover
|
||||
|
|
@ -65,7 +66,7 @@ const StyledButton: React.FC<StyledButtonProps> = ({
|
|||
|
||||
case 'secondary':
|
||||
return clsx(
|
||||
'bg-desert-tan text-desert-white',
|
||||
'bg-desert-tan text-white',
|
||||
'hover:bg-desert-tan-dark hover:shadow-lg',
|
||||
'active:bg-desert-tan-dark',
|
||||
'disabled:bg-desert-tan-lighter disabled:text-desert-stone-light',
|
||||
|
|
@ -75,7 +76,7 @@ const StyledButton: React.FC<StyledButtonProps> = ({
|
|||
|
||||
case 'danger':
|
||||
return clsx(
|
||||
'bg-desert-red text-desert-white',
|
||||
'bg-desert-red text-white',
|
||||
'hover:bg-desert-red-dark hover:shadow-lg',
|
||||
'active:bg-desert-red-dark',
|
||||
'disabled:bg-desert-red-lighter disabled:text-desert-stone-light',
|
||||
|
|
@ -85,7 +86,7 @@ const StyledButton: React.FC<StyledButtonProps> = ({
|
|||
|
||||
case 'action':
|
||||
return clsx(
|
||||
'bg-desert-orange text-desert-white',
|
||||
'bg-desert-orange text-white',
|
||||
'hover:bg-desert-orange-light hover:shadow-lg',
|
||||
'active:bg-desert-orange-dark',
|
||||
'disabled:bg-desert-orange-lighter disabled:text-desert-stone-light',
|
||||
|
|
@ -95,7 +96,7 @@ const StyledButton: React.FC<StyledButtonProps> = ({
|
|||
|
||||
case 'success':
|
||||
return clsx(
|
||||
'bg-desert-olive text-desert-white',
|
||||
'bg-desert-olive text-white',
|
||||
'hover:bg-desert-olive-dark hover:shadow-lg',
|
||||
'active:bg-desert-olive-dark',
|
||||
'disabled:bg-desert-olive-lighter disabled:text-desert-stone-light',
|
||||
|
|
@ -115,8 +116,8 @@ const StyledButton: React.FC<StyledButtonProps> = ({
|
|||
case 'outline':
|
||||
return clsx(
|
||||
'bg-transparent border-2 border-desert-green text-desert-green',
|
||||
'hover:bg-desert-green hover:text-desert-white hover:border-desert-green-dark',
|
||||
'active:bg-desert-green-dark active:border-desert-green-darker',
|
||||
'hover:bg-desert-green hover:text-white hover:border-btn-green-hover',
|
||||
'active:bg-btn-green-hover active:border-btn-green-active',
|
||||
'disabled:border-desert-green-lighter disabled:text-desert-stone-light',
|
||||
baseTransition,
|
||||
baseHover
|
||||
|
|
@ -152,7 +153,8 @@ const StyledButton: React.FC<StyledButtonProps> = ({
|
|||
getSizeClasses(),
|
||||
getVariantClasses(),
|
||||
isDisabled ? 'pointer-events-none opacity-60' : 'cursor-pointer',
|
||||
'items-center justify-center rounded-md font-semibold focus:outline-none focus:ring-2 focus:ring-desert-green-light focus:ring-offset-2 focus:ring-offset-desert-sand disabled:cursor-not-allowed disabled:shadow-none'
|
||||
'items-center justify-center rounded-md font-semibold focus:outline-none focus:ring-2 focus:ring-desert-green-light focus:ring-offset-2 focus:ring-offset-desert-sand disabled:cursor-not-allowed disabled:shadow-none',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
disabled={isDisabled}
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ const StyledModal: React.FC<StyledModalProps> = ({
|
|||
>
|
||||
<DialogBackdrop
|
||||
transition
|
||||
className="fixed inset-0 bg-gray-500/75 transition-opacity data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in"
|
||||
className="fixed inset-0 bg-black/50 transition-opacity data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in"
|
||||
/>
|
||||
<div className="fixed inset-0 z-10 w-screen overflow-y-auto">
|
||||
<div
|
||||
|
|
@ -60,14 +60,14 @@ const StyledModal: React.FC<StyledModalProps> = ({
|
|||
<DialogPanel
|
||||
transition
|
||||
className={classNames(
|
||||
'relative transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left shadow-xl transition-all data-[closed]:translate-y-4 data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in sm:my-8 sm:p-6 data-[closed]:sm:translate-y-0 data-[closed]:sm:scale-95',
|
||||
'relative transform overflow-hidden rounded-lg bg-surface-primary px-4 pb-4 pt-5 text-left shadow-xl transition-all data-[closed]:translate-y-4 data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in sm:my-8 sm:p-6 data-[closed]:sm:translate-y-0 data-[closed]:sm:scale-95',
|
||||
large ? 'sm:max-w-7xl !w-full' : 'sm:max-w-lg'
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
{icon && <div className="flex items-center justify-center">{icon}</div>}
|
||||
<div className="mt-3 text-center sm:mt-5">
|
||||
<DialogTitle as="h3" className="text-base font-semibold text-gray-900">
|
||||
<DialogTitle as="h3" className="text-base font-semibold text-text-primary">
|
||||
{title}
|
||||
</DialogTitle>
|
||||
<div className="mt-2 !h-fit">{children}</div>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import { useMemo, useState } from 'react'
|
||||
import { Dialog, DialogBackdrop, DialogPanel, TransitionChild } from '@headlessui/react'
|
||||
import classNames from '~/lib/classNames'
|
||||
import { IconArrowLeft } from '@tabler/icons-react'
|
||||
import { IconArrowLeft, IconBug } from '@tabler/icons-react'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
import { UsePageProps } from '../../types/system'
|
||||
import { IconMenu2, IconX } from '@tabler/icons-react'
|
||||
import ThemeToggle from '~/components/ThemeToggle'
|
||||
import DebugInfoModal from './DebugInfoModal'
|
||||
|
||||
type SidebarItem = {
|
||||
name: string
|
||||
|
|
@ -21,6 +23,7 @@ interface StyledSidebarProps {
|
|||
|
||||
const StyledSidebar: React.FC<StyledSidebarProps> = ({ title, items }) => {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
const [debugModalOpen, setDebugModalOpen] = useState(false)
|
||||
const { appVersion } = usePage().props as unknown as UsePageProps
|
||||
|
||||
const currentPath = useMemo(() => {
|
||||
|
|
@ -37,7 +40,7 @@ const StyledSidebar: React.FC<StyledSidebarProps> = ({ title, items }) => {
|
|||
className={classNames(
|
||||
item.current
|
||||
? 'bg-desert-green text-white'
|
||||
: 'text-black hover:bg-desert-green-light hover:text-white',
|
||||
: 'text-text-primary hover:bg-desert-green-light hover:text-white',
|
||||
'group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold'
|
||||
)}
|
||||
>
|
||||
|
|
@ -53,7 +56,7 @@ const StyledSidebar: React.FC<StyledSidebarProps> = ({ title, items }) => {
|
|||
<div className="flex grow flex-col gap-y-5 overflow-y-auto bg-desert-sand px-6 ring-1 ring-white/5 pt-4 shadow-md">
|
||||
<div className="flex h-16 shrink-0 items-center">
|
||||
<img src="/project_nomad_logo.png" alt="Project Nomad Logo" className="h-16 w-16" />
|
||||
<h1 className="ml-3 text-xl font-semibold text-black">{title}</h1>
|
||||
<h1 className="ml-3 text-xl font-semibold text-text-primary">{title}</h1>
|
||||
</div>
|
||||
<nav className="flex flex-1 flex-col">
|
||||
<ul role="list" className="flex flex-1 flex-col gap-y-7">
|
||||
|
|
@ -75,8 +78,16 @@ const StyledSidebar: React.FC<StyledSidebarProps> = ({ title, items }) => {
|
|||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<div className="mb-4 text-center text-sm text-gray-600">
|
||||
<div className="mb-4 flex flex-col items-center gap-1 text-sm text-text-secondary text-center">
|
||||
<p>Project N.O.M.A.D. Command Center v{appVersion}</p>
|
||||
<button
|
||||
onClick={() => setDebugModalOpen(true)}
|
||||
className="text-gray-500 hover:text-desert-green inline-flex items-center gap-1 cursor-pointer"
|
||||
>
|
||||
<IconBug className="size-3.5" />
|
||||
Debug Info
|
||||
</button>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -123,6 +134,7 @@ const StyledSidebar: React.FC<StyledSidebarProps> = ({ title, items }) => {
|
|||
<div className="hidden xl:fixed xl:inset-y-0 xl:z-50 xl:flex xl:w-72 xl:flex-col">
|
||||
<Sidebar />
|
||||
</div>
|
||||
<DebugInfoModal open={debugModalOpen} onClose={() => setDebugModalOpen(false)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,19 +74,19 @@ function StyledTable<T extends { [key: string]: any }>({
|
|||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'w-full overflow-x-auto bg-white ring-1 ring-gray-300 sm:mx-0 sm:rounded-lg p-1 shadow-md',
|
||||
'w-full overflow-x-auto bg-surface-primary ring-1 ring-border-default sm:mx-0 sm:rounded-lg p-1 shadow-md',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...containerProps}
|
||||
>
|
||||
<table className="min-w-full overflow-auto" {...restTableProps}>
|
||||
<thead className='border-b border-gray-200 '>
|
||||
<thead className='border-b border-border-subtle '>
|
||||
<tr>
|
||||
{expandable && (
|
||||
<th
|
||||
className={classNames(
|
||||
'whitespace-nowrap text-left font-semibold text-gray-900 w-12',
|
||||
'whitespace-nowrap text-left font-semibold text-text-primary w-12',
|
||||
compact ? `${leftPadding} py-2` : `${leftPadding} py-4 pr-3`
|
||||
)}
|
||||
/>
|
||||
|
|
@ -95,7 +95,7 @@ function StyledTable<T extends { [key: string]: any }>({
|
|||
<th
|
||||
key={index}
|
||||
className={classNames(
|
||||
'whitespace-nowrap text-left font-semibold text-gray-900',
|
||||
'whitespace-nowrap text-left font-semibold text-text-primary',
|
||||
compact ? `${leftPadding} py-2` : `${leftPadding} py-4 pr-3`
|
||||
)}
|
||||
>
|
||||
|
|
@ -121,8 +121,8 @@ function StyledTable<T extends { [key: string]: any }>({
|
|||
'translateY' in record ? 'translateY(' + record.transformY + 'px)' : undefined,
|
||||
}}
|
||||
className={classNames(
|
||||
rowLines ? 'border-b border-gray-200' : '',
|
||||
onRowClick ? `cursor-pointer hover:bg-gray-100 ` : ''
|
||||
rowLines ? 'border-b border-border-subtle' : '',
|
||||
onRowClick ? `cursor-pointer hover:bg-surface-secondary ` : ''
|
||||
)}
|
||||
>
|
||||
{expandable && (
|
||||
|
|
@ -134,7 +134,7 @@ function StyledTable<T extends { [key: string]: any }>({
|
|||
onClick={(e) => toggleRowExpansion(record, recordIdx, e)}
|
||||
>
|
||||
<button
|
||||
className="text-gray-500 hover:text-gray-700 focus:outline-none"
|
||||
className="text-text-muted hover:text-text-primary focus:outline-none"
|
||||
aria-label={isExpanded ? 'Collapse row' : 'Expand row'}
|
||||
>
|
||||
<svg
|
||||
|
|
@ -172,7 +172,7 @@ function StyledTable<T extends { [key: string]: any }>({
|
|||
))}
|
||||
</tr>
|
||||
{expandable && isExpanded && (
|
||||
<tr className="bg-gray-50">
|
||||
<tr className="bg-surface-secondary">
|
||||
<td colSpan={columns.length + 1}>
|
||||
{expandable.expandedRowRender(record, recordIdx)}
|
||||
</td>
|
||||
|
|
@ -183,7 +183,7 @@ function StyledTable<T extends { [key: string]: any }>({
|
|||
})}
|
||||
{!loading && data.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={columns.length + (expandable ? 1 : 0)} className="!text-center py-8 text-gray-500">
|
||||
<td colSpan={columns.length + (expandable ? 1 : 0)} className="!text-center py-8 text-text-muted">
|
||||
{noDataText}
|
||||
</td>
|
||||
</tr>
|
||||
|
|
|
|||
24
admin/inertia/components/ThemeToggle.tsx
Normal file
24
admin/inertia/components/ThemeToggle.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { IconSun, IconMoon } from '@tabler/icons-react'
|
||||
import { useThemeContext } from '~/providers/ThemeProvider'
|
||||
|
||||
interface ThemeToggleProps {
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
export default function ThemeToggle({ compact = false }: ThemeToggleProps) {
|
||||
const { theme, toggleTheme } = useThemeContext()
|
||||
const isDark = theme === 'dark'
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-sm transition-colors
|
||||
text-desert-stone hover:text-desert-green-darker cursor-pointer"
|
||||
aria-label={isDark ? 'Switch to Day Ops' : 'Switch to Night Ops'}
|
||||
title={isDark ? 'Switch to Day Ops' : 'Switch to Night Ops'}
|
||||
>
|
||||
{isDark ? <IconSun className="size-4" /> : <IconMoon className="size-4" />}
|
||||
{!compact && <span>{isDark ? 'Day Ops' : 'Night Ops'}</span>}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import { resolveTierResources } from '~/lib/collections'
|
|||
import { formatBytes } from '~/lib/util'
|
||||
import classNames from 'classnames'
|
||||
import DynamicIcon, { DynamicIconName } from './DynamicIcon'
|
||||
import StyledButton from './StyledButton'
|
||||
|
||||
interface TierSelectionModalProps {
|
||||
isOpen: boolean
|
||||
|
|
@ -88,7 +89,7 @@ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
|
|||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel className="w-full max-w-4xl transform overflow-hidden rounded-lg bg-white shadow-xl transition-all">
|
||||
<Dialog.Panel className="w-full max-w-4xl transform overflow-hidden rounded-lg bg-surface-primary shadow-xl transition-all">
|
||||
{/* Header */}
|
||||
<div className="bg-desert-green px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
|
|
@ -101,7 +102,7 @@ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
|
|||
<Dialog.Title className="text-xl font-semibold text-white">
|
||||
{category.name}
|
||||
</Dialog.Title>
|
||||
<p className="text-sm text-gray-200">{category.description}</p>
|
||||
<p className="text-sm text-text-muted">{category.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
|
|
@ -115,7 +116,7 @@ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
|
|||
|
||||
{/* Content */}
|
||||
<div className="p-6">
|
||||
<p className="text-gray-600 mb-6">
|
||||
<p className="text-text-secondary mb-6">
|
||||
Select a tier based on your storage capacity and needs. Higher tiers include all content from lower tiers.
|
||||
</p>
|
||||
|
||||
|
|
@ -138,30 +139,30 @@ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
|
|||
'border-2 rounded-lg p-5 cursor-pointer transition-all',
|
||||
isSelected
|
||||
? 'border-desert-green bg-desert-green/5 shadow-md'
|
||||
: 'border-gray-200 hover:border-desert-green/50 hover:shadow-sm'
|
||||
: 'border-border-subtle hover:border-desert-green/50 hover:shadow-sm'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
<h3 className="text-lg font-semibold text-text-primary">
|
||||
{tier.name}
|
||||
</h3>
|
||||
{includedTierName && (
|
||||
<span className="text-xs text-gray-500">
|
||||
<span className="text-xs text-text-muted">
|
||||
(includes {includedTierName})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-gray-600 text-sm mb-3">{tier.description}</p>
|
||||
<p className="text-text-secondary text-sm mb-3">{tier.description}</p>
|
||||
|
||||
{/* Resources preview - only show this tier's own resources */}
|
||||
<div className="bg-gray-50 rounded p-3">
|
||||
<p className="text-xs text-gray-500 mb-2 font-medium">
|
||||
<div className="bg-surface-secondary rounded p-3">
|
||||
<p className="text-xs text-text-muted mb-2 font-medium">
|
||||
{includedTierName ? (
|
||||
<>
|
||||
{ownResourceCount} additional {ownResourceCount === 1 ? 'resource' : 'resources'}
|
||||
<span className="text-gray-400"> (plus everything in {includedTierName})</span>
|
||||
<span className="text-text-muted"> (plus everything in {includedTierName})</span>
|
||||
</>
|
||||
) : (
|
||||
<>{ownResourceCount} {ownResourceCount === 1 ? 'resource' : 'resources'} included</>
|
||||
|
|
@ -172,8 +173,8 @@ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
|
|||
<div key={idx} className="flex items-start text-sm">
|
||||
<IconCheck size={14} className="text-desert-green mr-1.5 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<span className="text-gray-700">{resource.title}</span>
|
||||
<span className="text-gray-400 text-xs ml-1">
|
||||
<span className="text-text-primary">{resource.title}</span>
|
||||
<span className="text-text-muted text-xs ml-1">
|
||||
({formatBytes(resource.size_mb * 1024 * 1024, 0)})
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -184,14 +185,14 @@ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
|
|||
</div>
|
||||
|
||||
<div className="ml-4 text-right flex-shrink-0">
|
||||
<div className="text-lg font-semibold text-gray-900">
|
||||
<div className="text-lg font-semibold text-text-primary">
|
||||
{formatBytes(totalSize, 1)}
|
||||
</div>
|
||||
<div className={classNames(
|
||||
'w-6 h-6 rounded-full border-2 flex items-center justify-center mt-2 ml-auto',
|
||||
isSelected
|
||||
? 'border-desert-green bg-desert-green'
|
||||
: 'border-gray-300'
|
||||
: 'border-border-default'
|
||||
)}>
|
||||
{isSelected && <IconCheck size={16} className="text-white" />}
|
||||
</div>
|
||||
|
|
@ -203,7 +204,7 @@ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
|
|||
</div>
|
||||
|
||||
{/* Info note */}
|
||||
<div className="mt-6 flex items-start gap-2 text-sm text-gray-500 bg-blue-50 p-3 rounded">
|
||||
<div className="mt-6 flex items-start gap-2 text-sm text-text-muted bg-blue-50 p-3 rounded">
|
||||
<IconInfoCircle size={18} className="text-blue-500 flex-shrink-0 mt-0.5" />
|
||||
<p>
|
||||
You can change your selection at any time. Click Submit to confirm your choice.
|
||||
|
|
@ -212,19 +213,15 @@ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
|
|||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="bg-gray-50 px-6 py-4 flex justify-end gap-3">
|
||||
<button
|
||||
<div className="bg-surface-secondary px-6 py-4 flex justify-end gap-3">
|
||||
<StyledButton
|
||||
variant='primary'
|
||||
size='lg'
|
||||
onClick={handleSubmit}
|
||||
disabled={!localSelectedSlug}
|
||||
className={classNames(
|
||||
'px-4 py-2 rounded-md font-medium transition-colors',
|
||||
localSelectedSlug
|
||||
? 'bg-desert-green text-white hover:bg-desert-green/90'
|
||||
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
</StyledButton>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
|
|
|
|||
145
admin/inertia/components/UpdateServiceModal.tsx
Normal file
145
admin/inertia/components/UpdateServiceModal.tsx
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { useState } from "react"
|
||||
import { ServiceSlim } from "../../types/services"
|
||||
import StyledModal from "./StyledModal"
|
||||
import { IconArrowUp } from "@tabler/icons-react"
|
||||
import api from "~/lib/api"
|
||||
|
||||
|
||||
interface UpdateServiceModalProps {
|
||||
record: ServiceSlim
|
||||
currentTag: string
|
||||
latestVersion: string
|
||||
onCancel: () => void
|
||||
onUpdate: (version: string) => void
|
||||
showError: (msg: string) => void
|
||||
}
|
||||
|
||||
export default function UpdateServiceModal({
|
||||
record,
|
||||
currentTag,
|
||||
latestVersion,
|
||||
onCancel,
|
||||
onUpdate,
|
||||
showError,
|
||||
}: UpdateServiceModalProps) {
|
||||
const [selectedVersion, setSelectedVersion] = useState(latestVersion)
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
const [versions, setVersions] = useState<Array<{ tag: string; isLatest: boolean; releaseUrl?: string }>>([])
|
||||
const [loadingVersions, setLoadingVersions] = useState(false)
|
||||
|
||||
async function loadVersions() {
|
||||
if (versions.length > 0) return
|
||||
setLoadingVersions(true)
|
||||
try {
|
||||
const result = await api.getAvailableVersions(record.service_name)
|
||||
if (result?.versions) {
|
||||
setVersions(result.versions)
|
||||
}
|
||||
} catch (error) {
|
||||
showError('Failed to load available versions')
|
||||
} finally {
|
||||
setLoadingVersions(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleToggleAdvanced() {
|
||||
const next = !showAdvanced
|
||||
setShowAdvanced(next)
|
||||
if (next) loadVersions()
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledModal
|
||||
title="Update Service"
|
||||
onConfirm={() => onUpdate(selectedVersion)}
|
||||
onCancel={onCancel}
|
||||
open={true}
|
||||
confirmText="Update"
|
||||
cancelText="Cancel"
|
||||
confirmVariant="primary"
|
||||
icon={<IconArrowUp className="h-12 w-12 text-desert-green" />}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-text-primary">
|
||||
Update <strong>{record.friendly_name || record.service_name}</strong> from{' '}
|
||||
<code className="bg-surface-secondary px-1.5 py-0.5 rounded text-sm">{currentTag}</code> to{' '}
|
||||
<code className="bg-surface-secondary px-1.5 py-0.5 rounded text-sm">{selectedVersion}</code>?
|
||||
</p>
|
||||
<p className="text-sm text-text-muted">
|
||||
Your data and configuration will be preserved during the update.
|
||||
{versions.find((v) => v.tag === selectedVersion)?.releaseUrl && (
|
||||
<>
|
||||
{' '}
|
||||
<a
|
||||
href={versions.find((v) => v.tag === selectedVersion)!.releaseUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-desert-green hover:underline"
|
||||
>
|
||||
View release notes
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggleAdvanced}
|
||||
className="text-sm text-desert-green hover:underline font-medium"
|
||||
>
|
||||
{showAdvanced ? 'Hide' : 'Show'} available versions
|
||||
</button>
|
||||
|
||||
{showAdvanced && (
|
||||
<>
|
||||
<div className="mt-3 max-h-48 overflow-y-auto border rounded-lg divide-y">
|
||||
{loadingVersions ? (
|
||||
<div className="p-4 text-center text-text-muted text-sm">Loading versions...</div>
|
||||
) : versions.length === 0 ? (
|
||||
<div className="p-4 text-center text-text-muted text-sm">No other versions available</div>
|
||||
) : (
|
||||
versions.map((v) => (
|
||||
<label
|
||||
key={v.tag}
|
||||
className="flex items-center gap-3 px-4 py-2.5 hover:bg-surface-secondary cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="version"
|
||||
value={v.tag}
|
||||
checked={selectedVersion === v.tag}
|
||||
onChange={() => setSelectedVersion(v.tag)}
|
||||
className="text-desert-green focus:ring-desert-green"
|
||||
/>
|
||||
<span className="text-sm font-medium text-text-primary">{v.tag}</span>
|
||||
{v.isLatest && (
|
||||
<span className="text-xs bg-desert-green/10 text-desert-green px-2 py-0.5 rounded-full">
|
||||
Latest
|
||||
</span>
|
||||
)}
|
||||
{v.releaseUrl && (
|
||||
<a
|
||||
href={v.releaseUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="ml-auto text-xs text-desert-green hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
Release notes
|
||||
</a>
|
||||
)}
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-text-muted">
|
||||
It's not recommended to upgrade to a new major version (e.g. 1.8.2 → 2.0.0) unless you have verified compatibility with your current configuration. Always review the release notes and test in a staging environment if possible.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</StyledModal>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { formatBytes } from '~/lib/util'
|
||||
import { WikipediaOption, WikipediaCurrentSelection } from '../../types/downloads'
|
||||
import classNames from 'classnames'
|
||||
import { IconCheck, IconDownload, IconWorld } from '@tabler/icons-react'
|
||||
import { IconCheck, IconDownload, IconWorld, IconAlertTriangle } from '@tabler/icons-react'
|
||||
import StyledButton from './StyledButton'
|
||||
import LoadingSpinner from './LoadingSpinner'
|
||||
|
||||
|
|
@ -29,32 +29,45 @@ const WikipediaSelector: React.FC<WikipediaSelectorProps> = ({
|
|||
// Determine which option to highlight
|
||||
const highlightedOptionId = selectedOptionId ?? currentSelection?.optionId ?? null
|
||||
|
||||
// Check if current selection is downloading
|
||||
// Check if current selection is downloading or failed
|
||||
const isDownloading = currentSelection?.status === 'downloading'
|
||||
const isFailed = currentSelection?.status === 'failed'
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{/* Header with Wikipedia branding */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-full bg-white flex items-center justify-center shadow-sm">
|
||||
<IconWorld className="w-6 h-6 text-gray-700" />
|
||||
<IconWorld className="w-6 h-6 text-text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-gray-900">Wikipedia</h3>
|
||||
<p className="text-sm text-gray-500">Select your preferred Wikipedia package</p>
|
||||
<h3 className="text-xl font-semibold text-text-primary">Wikipedia</h3>
|
||||
<p className="text-sm text-text-muted">Select your preferred Wikipedia package</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Downloading status message */}
|
||||
{isDownloading && (
|
||||
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg flex items-center gap-2">
|
||||
<LoadingSpinner fullscreen={false} iconOnly className="size-5" />
|
||||
<LoadingSpinner fullscreen={false} iconOnly className="size-4" />
|
||||
<span className="text-sm text-blue-700">
|
||||
Downloading Wikipedia... This may take a while for larger packages.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Failed status message */}
|
||||
{isFailed && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<IconAlertTriangle className="w-5 h-5 text-red-600 flex-shrink-0" />
|
||||
<span className="text-sm text-red-700">
|
||||
Wikipedia download failed. Select a package and try again.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Options grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{options.map((option) => {
|
||||
|
|
@ -63,6 +76,8 @@ const WikipediaSelector: React.FC<WikipediaSelectorProps> = ({
|
|||
currentSelection?.optionId === option.id && currentSelection?.status === 'installed'
|
||||
const isCurrentDownloading =
|
||||
currentSelection?.optionId === option.id && currentSelection?.status === 'downloading'
|
||||
const isCurrentFailed =
|
||||
currentSelection?.optionId === option.id && currentSelection?.status === 'failed'
|
||||
const isPending = selectedOptionId === option.id && selectedOptionId !== currentSelection?.optionId
|
||||
|
||||
return (
|
||||
|
|
@ -78,7 +93,7 @@ const WikipediaSelector: React.FC<WikipediaSelectorProps> = ({
|
|||
? 'border-desert-green bg-desert-green/10'
|
||||
: isSelected
|
||||
? 'border-lime-500 bg-lime-50'
|
||||
: 'border-gray-200 bg-white hover:border-gray-300'
|
||||
: 'border-border-subtle bg-surface-primary hover:border-border-default'
|
||||
)}
|
||||
>
|
||||
{/* Status badges */}
|
||||
|
|
@ -100,12 +115,18 @@ const WikipediaSelector: React.FC<WikipediaSelectorProps> = ({
|
|||
Downloading
|
||||
</span>
|
||||
)}
|
||||
{isCurrentFailed && (
|
||||
<span className="text-xs bg-red-500 text-white px-2 py-0.5 rounded-full flex items-center gap-1">
|
||||
<IconAlertTriangle size={12} />
|
||||
Failed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Option content */}
|
||||
<div className="pr-16 flex flex-col h-full">
|
||||
<h4 className="text-lg font-semibold text-gray-900 mb-1">{option.name}</h4>
|
||||
<p className="text-sm text-gray-600 mb-3 flex-grow">{option.description}</p>
|
||||
<h4 className="text-lg font-semibold text-text-primary mb-1">{option.name}</h4>
|
||||
<p className="text-sm text-text-secondary mb-3 flex-grow">{option.description}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Radio indicator */}
|
||||
<div
|
||||
|
|
@ -115,7 +136,7 @@ const WikipediaSelector: React.FC<WikipediaSelectorProps> = ({
|
|||
? isInstalled
|
||||
? 'border-desert-green bg-desert-green'
|
||||
: 'border-lime-500 bg-lime-500'
|
||||
: 'border-gray-300'
|
||||
: 'border-border-default'
|
||||
)}
|
||||
>
|
||||
{isSelected && <IconCheck size={12} className="text-white" />}
|
||||
|
|
@ -123,7 +144,7 @@ const WikipediaSelector: React.FC<WikipediaSelectorProps> = ({
|
|||
<span
|
||||
className={classNames(
|
||||
'text-sm font-medium px-2 py-1 rounded',
|
||||
option.size_mb === 0 ? 'bg-gray-100 text-gray-500' : 'bg-gray-100 text-gray-700'
|
||||
option.size_mb === 0 ? 'bg-surface-secondary text-text-muted' : 'bg-surface-secondary text-text-secondary'
|
||||
)}
|
||||
>
|
||||
{option.size_mb === 0 ? 'No download' : formatBytes(option.size_mb * 1024 * 1024, 1)}
|
||||
|
|
@ -136,7 +157,7 @@ const WikipediaSelector: React.FC<WikipediaSelectorProps> = ({
|
|||
</div>
|
||||
|
||||
{/* Submit button for Content Explorer mode */}
|
||||
{showSubmitButton && selectedOptionId && selectedOptionId !== currentSelection?.optionId && (
|
||||
{showSubmitButton && selectedOptionId && (selectedOptionId !== currentSelection?.optionId || isFailed) && (
|
||||
<div className="mt-4 flex justify-end">
|
||||
<StyledButton
|
||||
variant="primary"
|
||||
|
|
|
|||
|
|
@ -5,6 +5,11 @@ import { ChatMessage } from '../../../types/chat'
|
|||
import ChatMessageBubble from './ChatMessageBubble'
|
||||
import ChatAssistantAvatar from './ChatAssistantAvatar'
|
||||
import BouncingDots from '../BouncingDots'
|
||||
import StyledModal from '../StyledModal'
|
||||
import api from '~/lib/api'
|
||||
import { DEFAULT_QUERY_REWRITE_MODEL } from '../../../constants/ollama'
|
||||
import { useNotifications } from '~/context/NotificationContext'
|
||||
import { usePage } from '@inertiajs/react'
|
||||
|
||||
interface ChatInterfaceProps {
|
||||
messages: ChatMessage[]
|
||||
|
|
@ -13,6 +18,7 @@ interface ChatInterfaceProps {
|
|||
chatSuggestions?: string[]
|
||||
chatSuggestionsEnabled?: boolean
|
||||
chatSuggestionsLoading?: boolean
|
||||
rewriteModelAvailable?: boolean
|
||||
}
|
||||
|
||||
export default function ChatInterface({
|
||||
|
|
@ -22,11 +28,29 @@ export default function ChatInterface({
|
|||
chatSuggestions = [],
|
||||
chatSuggestionsEnabled = false,
|
||||
chatSuggestionsLoading = false,
|
||||
rewriteModelAvailable = false
|
||||
}: ChatInterfaceProps) {
|
||||
const { aiAssistantName } = usePage<{ aiAssistantName: string }>().props
|
||||
const { addNotification } = useNotifications()
|
||||
const [input, setInput] = useState('')
|
||||
const [downloadDialogOpen, setDownloadDialogOpen] = useState(false)
|
||||
const [isDownloading, setIsDownloading] = useState(false)
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const handleDownloadModel = async () => {
|
||||
setIsDownloading(true)
|
||||
try {
|
||||
await api.downloadModel(DEFAULT_QUERY_REWRITE_MODEL)
|
||||
addNotification({ type: 'success', message: 'Model download queued' })
|
||||
} catch (error) {
|
||||
addNotification({ type: 'error', message: 'Failed to queue model download' })
|
||||
} finally {
|
||||
setIsDownloading(false)
|
||||
setDownloadDialogOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
|
|
@ -61,19 +85,19 @@ export default function ChatInterface({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0 bg-white shadow-sm">
|
||||
<div className="flex-1 flex flex-col min-h-0 bg-surface-primary shadow-sm">
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
|
||||
{messages.length === 0 ? (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-center max-w-md">
|
||||
<IconWand className="h-16 w-16 text-desert-green mx-auto mb-4 opacity-50" />
|
||||
<h3 className="text-lg font-medium text-gray-700 mb-2">Start a conversation</h3>
|
||||
<p className="text-gray-500 text-sm">
|
||||
<h3 className="text-lg font-medium text-text-primary mb-2">Start a conversation</h3>
|
||||
<p className="text-text-muted text-sm">
|
||||
Interact with your installed language models directly in the Command Center.
|
||||
</p>
|
||||
{chatSuggestionsEnabled && chatSuggestions && chatSuggestions.length > 0 && !chatSuggestionsLoading && (
|
||||
<div className="mt-8">
|
||||
<h4 className="text-sm font-medium text-gray-600 mb-2">Suggestions:</h4>
|
||||
<h4 className="text-sm font-medium text-text-secondary mb-2">Suggestions:</h4>
|
||||
<div className="flex flex-col gap-2">
|
||||
{chatSuggestions.map((suggestion, index) => (
|
||||
<button
|
||||
|
|
@ -85,7 +109,7 @@ export default function ChatInterface({
|
|||
textareaRef.current?.focus()
|
||||
}, 0)
|
||||
}}
|
||||
className="px-4 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm text-gray-700 transition-colors"
|
||||
className="px-4 py-2 bg-surface-secondary hover:bg-surface-secondary rounded-lg text-sm text-text-primary transition-colors"
|
||||
>
|
||||
{suggestion}
|
||||
</button>
|
||||
|
|
@ -96,7 +120,7 @@ export default function ChatInterface({
|
|||
{/* Display bouncing dots while loading suggestions */}
|
||||
{chatSuggestionsEnabled && chatSuggestionsLoading && <BouncingDots text="Thinking" containerClassName="mt-8" />}
|
||||
{!chatSuggestionsEnabled && (
|
||||
<div className="mt-8 text-sm text-gray-500">
|
||||
<div className="mt-8 text-sm text-text-muted">
|
||||
Need some inspiration? Enable chat suggestions in settings to get started with example prompts.
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -120,7 +144,7 @@ export default function ChatInterface({
|
|||
{isLoading && (
|
||||
<div className="flex gap-4 justify-start">
|
||||
<ChatAssistantAvatar />
|
||||
<div className="max-w-[70%] rounded-lg px-4 py-3 bg-gray-100 text-gray-800">
|
||||
<div className="max-w-[70%] rounded-lg px-4 py-3 bg-surface-secondary text-text-primary">
|
||||
<BouncingDots text="Thinking" />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -130,7 +154,7 @@ export default function ChatInterface({
|
|||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="border-t border-gray-200 bg-white px-6 py-4 flex-shrink-0 min-h-[90px]">
|
||||
<div className="border-t border-border-subtle bg-surface-primary px-6 py-4 flex-shrink-0 min-h-[90px]">
|
||||
<form onSubmit={handleSubmit} className="flex gap-3 items-end">
|
||||
<div className="flex-1 relative">
|
||||
<textarea
|
||||
|
|
@ -138,8 +162,8 @@ export default function ChatInterface({
|
|||
value={input}
|
||||
onChange={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type your message... (Shift+Enter for new line)"
|
||||
className="w-full resize-none rounded-lg border border-gray-300 px-4 py-3 pr-12 focus:outline-none focus:ring-2 focus:ring-desert-green focus:border-transparent disabled:bg-gray-50 disabled:text-gray-500"
|
||||
placeholder={`Type your message to ${aiAssistantName}... (Shift+Enter for new line)`}
|
||||
className="w-full resize-none rounded-lg border border-border-default px-4 py-3 pr-12 focus:outline-none focus:ring-2 focus:ring-desert-green focus:border-transparent disabled:bg-surface-secondary disabled:text-text-muted"
|
||||
rows={1}
|
||||
disabled={isLoading}
|
||||
style={{ maxHeight: '200px' }}
|
||||
|
|
@ -151,7 +175,7 @@ export default function ChatInterface({
|
|||
className={classNames(
|
||||
'p-3 rounded-lg transition-all duration-200 flex-shrink-0 mb-2',
|
||||
!input.trim() || isLoading
|
||||
? 'bg-gray-300 text-gray-500 cursor-not-allowed'
|
||||
? 'bg-border-default text-text-muted cursor-not-allowed'
|
||||
: 'bg-desert-green text-white hover:bg-desert-green/90 hover:scale-105'
|
||||
)}
|
||||
>
|
||||
|
|
@ -162,6 +186,36 @@ export default function ChatInterface({
|
|||
)}
|
||||
</button>
|
||||
</form>
|
||||
{!rewriteModelAvailable && (
|
||||
<div className="text-sm text-text-muted mt-2">
|
||||
The {DEFAULT_QUERY_REWRITE_MODEL} model is not installed. Consider{' '}
|
||||
<button
|
||||
onClick={() => setDownloadDialogOpen(true)}
|
||||
className="text-desert-green underline hover:text-desert-green/80 cursor-pointer"
|
||||
>
|
||||
downloading it
|
||||
</button>{' '}
|
||||
for improved retrieval-augmented generation (RAG) performance.
|
||||
</div>
|
||||
)}
|
||||
<StyledModal
|
||||
open={downloadDialogOpen}
|
||||
title={`Download ${DEFAULT_QUERY_REWRITE_MODEL}?`}
|
||||
confirmText="Download"
|
||||
cancelText="Cancel"
|
||||
confirmIcon='IconDownload'
|
||||
confirmVariant='primary'
|
||||
confirmLoading={isDownloading}
|
||||
onConfirm={handleDownloadModel}
|
||||
onCancel={() => setDownloadDialogOpen(false)}
|
||||
onClose={() => setDownloadDialogOpen(false)}
|
||||
>
|
||||
<p className="text-text-primary">
|
||||
This will dispatch a background download job for{' '}
|
||||
<span className="font-mono font-medium">{DEFAULT_QUERY_REWRITE_MODEL}</span> and may take some time to complete. The model
|
||||
will be used to rewrite queries for improved RAG retrieval performance.
|
||||
</p>
|
||||
</StyledModal>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ export default function ChatMessageBubble({ message }: ChatMessageBubbleProps) {
|
|||
<div
|
||||
className={classNames(
|
||||
'max-w-[70%] rounded-lg px-4 py-3',
|
||||
message.role === 'user' ? 'bg-desert-green text-white' : 'bg-gray-100 text-gray-800'
|
||||
message.role === 'user' ? 'bg-desert-green text-white' : 'bg-surface-secondary text-text-primary'
|
||||
)}
|
||||
>
|
||||
{message.isThinking && message.thinking && (
|
||||
|
|
@ -27,11 +27,13 @@ export default function ChatMessageBubble({ message }: ChatMessageBubbleProps) {
|
|||
</div>
|
||||
)}
|
||||
{!message.isThinking && message.thinking && (
|
||||
<details className="mb-3 rounded border border-gray-200 bg-gray-50 text-xs">
|
||||
<summary className="cursor-pointer px-3 py-2 font-medium text-gray-500 hover:text-gray-700 select-none">
|
||||
Reasoning
|
||||
<details className="mb-3 rounded border border-border-subtle bg-surface-secondary text-xs">
|
||||
<summary className="cursor-pointer px-3 py-2 font-medium text-text-muted hover:text-text-primary select-none">
|
||||
{message.thinkingDuration !== undefined
|
||||
? `Thought for ${message.thinkingDuration}s`
|
||||
: 'Reasoning'}
|
||||
</summary>
|
||||
<div className="px-3 pb-3 prose prose-xs max-w-none text-gray-600 max-h-48 overflow-y-auto border-t border-gray-200 pt-2">
|
||||
<div className="px-3 pb-3 prose prose-xs max-w-none text-text-secondary max-h-48 overflow-y-auto border-t border-border-subtle pt-2">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{message.thinking}</ReactMarkdown>
|
||||
</div>
|
||||
</details>
|
||||
|
|
@ -75,7 +77,7 @@ export default function ChatMessageBubble({ message }: ChatMessageBubbleProps) {
|
|||
h2: ({ children }) => <h2 className="text-lg font-bold mb-2">{children}</h2>,
|
||||
h3: ({ children }) => <h3 className="text-base font-bold mb-2">{children}</h3>,
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-4 border-gray-400 pl-4 italic my-2">
|
||||
<blockquote className="border-l-4 border-border-default pl-4 italic my-2">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
|
|
@ -103,7 +105,7 @@ export default function ChatMessageBubble({ message }: ChatMessageBubbleProps) {
|
|||
<div
|
||||
className={classNames(
|
||||
'text-xs mt-2',
|
||||
message.role === 'user' ? 'text-white/70' : 'text-gray-500'
|
||||
message.role === 'user' ? 'text-white/70' : 'text-text-muted'
|
||||
)}
|
||||
>
|
||||
{message.timestamp.toLocaleTimeString([], {
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export default function ChatModal({ open, onClose }: ChatModalProps) {
|
|||
<div className="fixed inset-0 flex items-center justify-center p-4">
|
||||
<DialogPanel
|
||||
transition
|
||||
className="relative bg-white rounded-xl shadow-2xl w-full max-w-7xl h-[85vh] flex overflow-hidden transition-all data-[closed]:scale-95 data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in"
|
||||
className="relative bg-surface-primary rounded-xl shadow-2xl w-full max-w-7xl h-[85vh] flex overflow-hidden transition-all data-[closed]:scale-95 data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in"
|
||||
>
|
||||
<Chat enabled={open} isInModal onClose={onClose} suggestionsEnabled={parseBoolean(settings.data?.value)} />
|
||||
</DialogPanel>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import classNames from '~/lib/classNames'
|
||||
import StyledButton from '../StyledButton'
|
||||
import { router } from '@inertiajs/react'
|
||||
import { router, usePage } from '@inertiajs/react'
|
||||
import { ChatSession } from '../../../types/chat'
|
||||
import { IconMessage } from '@tabler/icons-react'
|
||||
import { useState } from 'react'
|
||||
|
|
@ -23,6 +23,7 @@ export default function ChatSidebar({
|
|||
onClearHistory,
|
||||
isInModal = false,
|
||||
}: ChatSidebarProps) {
|
||||
const { aiAssistantName } = usePage<{ aiAssistantName: string }>().props
|
||||
const [isKnowledgeBaseModalOpen, setIsKnowledgeBaseModalOpen] = useState(
|
||||
() => new URLSearchParams(window.location.search).get('knowledge_base') === 'true'
|
||||
)
|
||||
|
|
@ -38,8 +39,8 @@ export default function ChatSidebar({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="w-64 bg-gray-50 border-r border-gray-200 flex flex-col h-full">
|
||||
<div className="p-4 border-b border-gray-200 h-[75px] flex items-center justify-center">
|
||||
<div className="w-64 bg-surface-secondary border-r border-border-subtle flex flex-col h-full">
|
||||
<div className="p-4 border-b border-border-subtle h-[75px] flex items-center justify-center">
|
||||
<StyledButton onClick={onNewChat} icon="IconPlus" variant="primary" fullWidth>
|
||||
New Chat
|
||||
</StyledButton>
|
||||
|
|
@ -47,7 +48,7 @@ export default function ChatSidebar({
|
|||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{sessions.length === 0 ? (
|
||||
<div className="p-4 text-center text-gray-500 text-sm">No previous chats</div>
|
||||
<div className="p-4 text-center text-text-muted text-sm">No previous chats</div>
|
||||
) : (
|
||||
<div className="p-2 space-y-1">
|
||||
{sessions.map((session) => (
|
||||
|
|
@ -58,14 +59,14 @@ export default function ChatSidebar({
|
|||
'w-full text-left px-3 py-2 rounded-lg transition-colors group',
|
||||
activeSessionId === session.id
|
||||
? 'bg-desert-green text-white'
|
||||
: 'hover:bg-gray-200 text-gray-700'
|
||||
: 'hover:bg-surface-secondary text-text-primary'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<IconMessage
|
||||
className={classNames(
|
||||
'h-5 w-5 mt-0.5 shrink-0',
|
||||
activeSessionId === session.id ? 'text-white' : 'text-gray-400'
|
||||
activeSessionId === session.id ? 'text-white' : 'text-text-muted'
|
||||
)}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
|
|
@ -74,7 +75,7 @@ export default function ChatSidebar({
|
|||
<div
|
||||
className={classNames(
|
||||
'text-xs truncate mt-0.5',
|
||||
activeSessionId === session.id ? 'text-white/80' : 'text-gray-500'
|
||||
activeSessionId === session.id ? 'text-white/80' : 'text-text-muted'
|
||||
)}
|
||||
>
|
||||
{session.lastMessage}
|
||||
|
|
@ -139,7 +140,7 @@ export default function ChatSidebar({
|
|||
)}
|
||||
</div>
|
||||
{isKnowledgeBaseModalOpen && (
|
||||
<KnowledgeBaseModal onClose={handleCloseKnowledgeBase} />
|
||||
<KnowledgeBaseModal aiAssistantName={aiAssistantName} onClose={handleCloseKnowledgeBase} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useRef, useState } from 'react'
|
||||
import FileUploader from '~/components/file-uploader'
|
||||
import StyledButton from '~/components/StyledButton'
|
||||
|
|
@ -9,16 +9,25 @@ import api from '~/lib/api'
|
|||
import { IconX } from '@tabler/icons-react'
|
||||
import { useModals } from '~/context/ModalContext'
|
||||
import StyledModal from '../StyledModal'
|
||||
import ActiveEmbedJobs from '~/components/ActiveEmbedJobs'
|
||||
|
||||
interface KnowledgeBaseModalProps {
|
||||
aiAssistantName?: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function KnowledgeBaseModal({ onClose }: KnowledgeBaseModalProps) {
|
||||
function sourceToDisplayName(source: string): string {
|
||||
const parts = source.split(/[/\\]/)
|
||||
return parts[parts.length - 1]
|
||||
}
|
||||
|
||||
export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", onClose }: KnowledgeBaseModalProps) {
|
||||
const { addNotification } = useNotifications()
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
const [confirmDeleteSource, setConfirmDeleteSource] = useState<string | null>(null)
|
||||
const fileUploaderRef = useRef<React.ComponentRef<typeof FileUploader>>(null)
|
||||
const { openModal, closeModal } = useModals()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data: storedFiles = [], isLoading: isLoadingFiles } = useQuery({
|
||||
queryKey: ['storedFiles'],
|
||||
|
|
@ -46,6 +55,19 @@ export default function KnowledgeBaseModal({ onClose }: KnowledgeBaseModalProps)
|
|||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (source: string) => api.deleteRAGFile(source),
|
||||
onSuccess: () => {
|
||||
addNotification({ type: 'success', message: 'File removed from knowledge base.' })
|
||||
setConfirmDeleteSource(null)
|
||||
queryClient.invalidateQueries({ queryKey: ['storedFiles'] })
|
||||
},
|
||||
onError: (error: any) => {
|
||||
addNotification({ type: 'error', message: error?.message || 'Failed to delete file.' })
|
||||
setConfirmDeleteSource(null)
|
||||
},
|
||||
})
|
||||
|
||||
const syncMutation = useMutation({
|
||||
mutationFn: () => api.syncRAGStorage(),
|
||||
onSuccess: (data) => {
|
||||
|
|
@ -84,7 +106,7 @@ export default function KnowledgeBaseModal({ onClose }: KnowledgeBaseModalProps)
|
|||
cancelText='Cancel'
|
||||
confirmVariant='primary'
|
||||
>
|
||||
<p className='text-gray-700'>
|
||||
<p className='text-text-primary'>
|
||||
This will scan the NOMAD's storage directories for any new files and queue them for processing. This is useful if you've manually added files to the storage or want to ensure everything is up to date.
|
||||
This may cause a temporary increase in resource usage if new files are found and being processed. Are you sure you want to proceed?
|
||||
</p>
|
||||
|
|
@ -95,18 +117,18 @@ export default function KnowledgeBaseModal({ onClose }: KnowledgeBaseModalProps)
|
|||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/30 backdrop-blur-sm transition-opacity">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 shrink-0">
|
||||
<h2 className="text-2xl font-semibold text-gray-800">Knowledge Base</h2>
|
||||
<div className="bg-surface-primary rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<div className="flex items-center justify-between p-6 border-b border-border-subtle shrink-0">
|
||||
<h2 className="text-2xl font-semibold text-text-primary">Knowledge Base</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
className="p-2 hover:bg-surface-secondary rounded-lg transition-colors"
|
||||
>
|
||||
<IconX className="h-6 w-6 text-gray-500" />
|
||||
<IconX className="h-6 w-6 text-text-muted" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-y-auto flex-1 p-6">
|
||||
<div className="bg-white rounded-lg border shadow-md overflow-hidden">
|
||||
<div className="bg-surface-primary rounded-lg border shadow-md overflow-hidden">
|
||||
<div className="p-6">
|
||||
<FileUploader
|
||||
ref={fileUploaderRef}
|
||||
|
|
@ -129,7 +151,7 @@ export default function KnowledgeBaseModal({ onClose }: KnowledgeBaseModalProps)
|
|||
</StyledButton>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t bg-white p-6">
|
||||
<div className="border-t bg-surface-primary p-6">
|
||||
<h3 className="text-lg font-semibold text-desert-green mb-4">
|
||||
Why upload documents to your Knowledge Base?
|
||||
</h3>
|
||||
|
|
@ -140,12 +162,12 @@ export default function KnowledgeBaseModal({ onClose }: KnowledgeBaseModalProps)
|
|||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-desert-stone-dark">
|
||||
AI Assistant Knowledge Base Integration
|
||||
{aiAssistantName} Knowledge Base Integration
|
||||
</p>
|
||||
<p className="text-sm text-desert-stone">
|
||||
When you upload documents to your Knowledge Base, NOMAD processes and embeds
|
||||
the content, making it directly accessible to the AI Assistant. This allows
|
||||
the AI Assistant to reference your specific documents during conversations,
|
||||
the content, making it directly accessible to {aiAssistantName}. This allows{' '}
|
||||
{aiAssistantName} to reference your specific documents during conversations,
|
||||
providing more accurate and personalized responses based on your uploaded
|
||||
data.
|
||||
</p>
|
||||
|
|
@ -177,14 +199,17 @@ export default function KnowledgeBaseModal({ onClose }: KnowledgeBaseModalProps)
|
|||
</p>
|
||||
<p className="text-sm text-desert-stone">
|
||||
NOMAD will automatically discover and extract any content you save to your
|
||||
Information Library (if installed), making it instantly available to the AI
|
||||
Assistant without any extra steps.
|
||||
Information Library (if installed), making it instantly available to {aiAssistantName} without any extra steps.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="my-8">
|
||||
<ActiveEmbedJobs withHeader={true} />
|
||||
</div>
|
||||
|
||||
<div className="my-12">
|
||||
<div className='flex items-center justify-between mb-6'>
|
||||
<StyledSectionHeader title="Stored Knowledge Base Files" className='!mb-0' />
|
||||
|
|
@ -207,7 +232,50 @@ export default function KnowledgeBaseModal({ onClose }: KnowledgeBaseModalProps)
|
|||
accessor: 'source',
|
||||
title: 'File Name',
|
||||
render(record) {
|
||||
return <span className="text-gray-700">{record.source}</span>
|
||||
return <span className="text-text-primary">{sourceToDisplayName(record.source)}</span>
|
||||
},
|
||||
},
|
||||
{
|
||||
accessor: 'source',
|
||||
title: '',
|
||||
render(record) {
|
||||
const isConfirming = confirmDeleteSource === record.source
|
||||
const isDeleting = deleteMutation.isPending && confirmDeleteSource === record.source
|
||||
if (isConfirming) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<span className="text-sm text-text-secondary">Remove from knowledge base?</span>
|
||||
<StyledButton
|
||||
variant='danger'
|
||||
size='sm'
|
||||
onClick={() => deleteMutation.mutate(record.source)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? 'Deleting…' : 'Confirm'}
|
||||
</StyledButton>
|
||||
<StyledButton
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => setConfirmDeleteSource(null)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
Cancel
|
||||
</StyledButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<StyledButton
|
||||
variant="danger"
|
||||
size="sm"
|
||||
icon="IconTrash"
|
||||
onClick={() => setConfirmDeleteSource(record.source)}
|
||||
disabled={deleteMutation.isPending}
|
||||
loading={deleteMutation.isPending && confirmDeleteSource === record.source}
|
||||
>Delete</StyledButton>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
]}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import { useState, useCallback, useEffect, useRef, useMemo } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import ChatSidebar from './ChatSidebar'
|
||||
import ChatInterface from './ChatInterface'
|
||||
|
|
@ -9,6 +9,8 @@ import { useModals } from '~/context/ModalContext'
|
|||
import { ChatMessage } from '../../../types/chat'
|
||||
import classNames from '~/lib/classNames'
|
||||
import { IconX } from '@tabler/icons-react'
|
||||
import { DEFAULT_QUERY_REWRITE_MODEL } from '../../../constants/ollama'
|
||||
import { useSystemSetting } from '~/hooks/useSystemSetting'
|
||||
|
||||
interface ChatProps {
|
||||
enabled: boolean
|
||||
|
|
@ -50,6 +52,8 @@ export default function Chat({
|
|||
|
||||
const activeSession = sessions.find((s) => s.id === activeSessionId)
|
||||
|
||||
const { data: lastModelSetting } = useSystemSetting({ key: 'chat.lastModel', enabled })
|
||||
|
||||
const { data: installedModels = [], isLoading: isLoadingModels } = useQuery({
|
||||
queryKey: ['installedModels'],
|
||||
queryFn: () => api.getInstalledModels(),
|
||||
|
|
@ -68,6 +72,10 @@ export default function Chat({
|
|||
refetchOnMount: false,
|
||||
})
|
||||
|
||||
const rewriteModelAvailable = useMemo(() => {
|
||||
return installedModels.some(model => model.name === DEFAULT_QUERY_REWRITE_MODEL)
|
||||
}, [installedModels])
|
||||
|
||||
const deleteAllSessionsMutation = useMutation({
|
||||
mutationFn: () => api.deleteAllChatSessions(),
|
||||
onSuccess: () => {
|
||||
|
|
@ -82,8 +90,9 @@ export default function Chat({
|
|||
mutationFn: (request: {
|
||||
model: string
|
||||
messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>
|
||||
sessionId?: number
|
||||
}) => api.sendChatMessage({ ...request, stream: false }),
|
||||
onSuccess: async (data, variables) => {
|
||||
onSuccess: async (data) => {
|
||||
if (!data || !activeSessionId) {
|
||||
throw new Error('No response from Ollama')
|
||||
}
|
||||
|
|
@ -98,17 +107,9 @@ export default function Chat({
|
|||
|
||||
setMessages((prev) => [...prev, assistantMessage])
|
||||
|
||||
// Save assistant message to backend
|
||||
await api.addChatMessage(activeSessionId, 'assistant', assistantMessage.content)
|
||||
|
||||
// Update session title if it's a new chat
|
||||
const currentSession = sessions.find((s) => s.id === activeSessionId)
|
||||
if (currentSession && currentSession.title === 'New Chat') {
|
||||
const userContent = variables.messages[variables.messages.length - 1].content
|
||||
const newTitle = userContent.slice(0, 50) + (userContent.length > 50 ? '...' : '')
|
||||
await api.updateChatSession(activeSessionId, { title: newTitle })
|
||||
queryClient.invalidateQueries({ queryKey: ['chatSessions'] })
|
||||
}
|
||||
// Refresh sessions to pick up backend-persisted messages and title
|
||||
queryClient.invalidateQueries({ queryKey: ['chatSessions'] })
|
||||
setTimeout(() => queryClient.invalidateQueries({ queryKey: ['chatSessions'] }), 3000)
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Error sending message:', error)
|
||||
|
|
@ -122,12 +123,24 @@ export default function Chat({
|
|||
},
|
||||
})
|
||||
|
||||
// Set first model as selected by default
|
||||
// Set default model: prefer last used model, fall back to first installed if last model not available
|
||||
useEffect(() => {
|
||||
if (installedModels.length > 0 && !selectedModel) {
|
||||
setSelectedModel(installedModels[0].name)
|
||||
const lastModel = lastModelSetting?.value as string | undefined
|
||||
if (lastModel && installedModels.some((m) => m.name === lastModel)) {
|
||||
setSelectedModel(lastModel)
|
||||
} else {
|
||||
setSelectedModel(installedModels[0].name)
|
||||
}
|
||||
}
|
||||
}, [installedModels, selectedModel])
|
||||
}, [installedModels, selectedModel, lastModelSetting])
|
||||
|
||||
// Persist model selection
|
||||
useEffect(() => {
|
||||
if (selectedModel) {
|
||||
api.updateSetting('chat.lastModel', selectedModel)
|
||||
}
|
||||
}, [selectedModel])
|
||||
|
||||
const handleNewChat = useCallback(() => {
|
||||
// Just clear the active session and messages - don't create a session yet
|
||||
|
|
@ -146,7 +159,7 @@ export default function Chat({
|
|||
cancelText="Cancel"
|
||||
confirmVariant="danger"
|
||||
>
|
||||
<p className="text-gray-700">
|
||||
<p className="text-text-primary">
|
||||
Are you sure you want to delete all chat sessions? This action cannot be undone and all
|
||||
conversations will be permanently deleted.
|
||||
</p>
|
||||
|
|
@ -159,7 +172,7 @@ export default function Chat({
|
|||
async (sessionId: string) => {
|
||||
// Cancel any ongoing suggestions fetch
|
||||
queryClient.cancelQueries({ queryKey: ['chatSuggestions'] })
|
||||
|
||||
|
||||
setActiveSessionId(sessionId)
|
||||
// Load messages for this session
|
||||
const sessionData = await api.getChatSession(sessionId)
|
||||
|
|
@ -210,9 +223,6 @@ export default function Chat({
|
|||
|
||||
setMessages((prev) => [...prev, userMessage])
|
||||
|
||||
// Save user message to backend
|
||||
await api.addChatMessage(sessionId, 'user', content)
|
||||
|
||||
const chatMessages = [
|
||||
...messages.map((m) => ({ role: m.role, content: m.content })),
|
||||
{ role: 'user' as const, content },
|
||||
|
|
@ -230,11 +240,16 @@ export default function Chat({
|
|||
let fullContent = ''
|
||||
let thinkingContent = ''
|
||||
let isThinkingPhase = true
|
||||
let thinkingStartTime: number | null = null
|
||||
let thinkingDuration: number | null = null
|
||||
|
||||
try {
|
||||
await api.streamChatMessage(
|
||||
{ model: selectedModel || 'llama3.2', messages: chatMessages, stream: true },
|
||||
{ model: selectedModel || 'llama3.2', messages: chatMessages, stream: true, sessionId: sessionId ? Number(sessionId) : undefined },
|
||||
(chunkContent, chunkThinking, done) => {
|
||||
if (chunkThinking.length > 0 && thinkingStartTime === null) {
|
||||
thinkingStartTime = Date.now()
|
||||
}
|
||||
if (isFirstChunk) {
|
||||
isFirstChunk = false
|
||||
setIsStreamingResponse(false)
|
||||
|
|
@ -248,22 +263,27 @@ export default function Chat({
|
|||
timestamp: new Date(),
|
||||
isStreaming: true,
|
||||
isThinking: chunkThinking.length > 0 && chunkContent.length === 0,
|
||||
thinkingDuration: undefined,
|
||||
},
|
||||
])
|
||||
} else {
|
||||
if (isThinkingPhase && chunkContent.length > 0) {
|
||||
isThinkingPhase = false
|
||||
if (thinkingStartTime !== null) {
|
||||
thinkingDuration = Math.max(1, Math.round((Date.now() - thinkingStartTime) / 1000))
|
||||
}
|
||||
}
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === assistantMsgId
|
||||
? {
|
||||
...m,
|
||||
content: m.content + chunkContent,
|
||||
thinking: (m.thinking ?? '') + chunkThinking,
|
||||
isStreaming: !done,
|
||||
isThinking: isThinkingPhase,
|
||||
}
|
||||
...m,
|
||||
content: m.content + chunkContent,
|
||||
thinking: (m.thinking ?? '') + chunkThinking,
|
||||
isStreaming: !done,
|
||||
isThinking: isThinkingPhase,
|
||||
thinkingDuration: thinkingDuration ?? undefined,
|
||||
}
|
||||
: m
|
||||
)
|
||||
)
|
||||
|
|
@ -306,30 +326,26 @@ export default function Chat({
|
|||
)
|
||||
)
|
||||
|
||||
await api.addChatMessage(sessionId, 'assistant', fullContent)
|
||||
|
||||
const currentSession = sessions.find((s) => s.id === sessionId)
|
||||
if (currentSession && currentSession.title === 'New Chat') {
|
||||
const newTitle = content.slice(0, 50) + (content.length > 50 ? '...' : '')
|
||||
await api.updateChatSession(sessionId, { title: newTitle })
|
||||
queryClient.invalidateQueries({ queryKey: ['chatSessions'] })
|
||||
}
|
||||
// Refresh sessions to pick up backend-persisted messages and title
|
||||
queryClient.invalidateQueries({ queryKey: ['chatSessions'] })
|
||||
setTimeout(() => queryClient.invalidateQueries({ queryKey: ['chatSessions'] }), 3000)
|
||||
}
|
||||
} else {
|
||||
// Non-streaming (legacy) path
|
||||
chatMutation.mutate({
|
||||
model: selectedModel || 'llama3.2',
|
||||
messages: chatMessages,
|
||||
sessionId: sessionId ? Number(sessionId) : undefined,
|
||||
})
|
||||
}
|
||||
},
|
||||
[activeSessionId, messages, selectedModel, chatMutation, queryClient, streamingEnabled, sessions]
|
||||
[activeSessionId, messages, selectedModel, chatMutation, queryClient, streamingEnabled]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'flex border border-gray-200 overflow-hidden shadow-sm w-full',
|
||||
'flex border border-border-subtle overflow-hidden shadow-sm w-full',
|
||||
isInModal ? 'h-full rounded-lg' : 'h-screen'
|
||||
)}
|
||||
>
|
||||
|
|
@ -342,17 +358,17 @@ export default function Chat({
|
|||
isInModal={isInModal}
|
||||
/>
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<div className="px-6 py-3 border-b border-gray-200 bg-gray-50 flex items-center justify-between h-[75px] flex-shrink-0">
|
||||
<h2 className="text-lg font-semibold text-gray-800">
|
||||
<div className="px-6 py-3 border-b border-border-subtle bg-surface-secondary flex items-center justify-between h-[75px] flex-shrink-0">
|
||||
<h2 className="text-lg font-semibold text-text-primary">
|
||||
{activeSession?.title || 'New Chat'}
|
||||
</h2>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<label htmlFor="model-select" className="text-sm text-gray-600">
|
||||
<label htmlFor="model-select" className="text-sm text-text-secondary">
|
||||
Model:
|
||||
</label>
|
||||
{isLoadingModels ? (
|
||||
<div className="text-sm text-gray-500">Loading models...</div>
|
||||
<div className="text-sm text-text-muted">Loading models...</div>
|
||||
) : installedModels.length === 0 ? (
|
||||
<div className="text-sm text-red-600">No models installed</div>
|
||||
) : (
|
||||
|
|
@ -360,7 +376,7 @@ export default function Chat({
|
|||
id="model-select"
|
||||
value={selectedModel}
|
||||
onChange={(e) => setSelectedModel(e.target.value)}
|
||||
className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-desert-green focus:border-transparent bg-white"
|
||||
className="px-3 py-1.5 border border-border-default rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-desert-green focus:border-transparent bg-surface-primary"
|
||||
>
|
||||
{installedModels.map((model) => (
|
||||
<option key={model.name} value={model.name}>
|
||||
|
|
@ -377,9 +393,9 @@ export default function Chat({
|
|||
onClose()
|
||||
}
|
||||
}}
|
||||
className="rounded-lg hover:bg-gray-100 transition-colors"
|
||||
className="rounded-lg hover:bg-surface-secondary transition-colors"
|
||||
>
|
||||
<IconX className="h-6 w-6 text-gray-500" />
|
||||
<IconX className="h-6 w-6 text-text-muted" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -391,6 +407,7 @@ export default function Chat({
|
|||
chatSuggestions={chatSuggestions}
|
||||
chatSuggestionsEnabled={suggestionsEnabled}
|
||||
chatSuggestionsLoading={chatSuggestionsLoading}
|
||||
rewriteModelAvailable={rewriteModelAvailable}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { InputHTMLAttributes } from "react";
|
|||
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
name: string;
|
||||
label: string;
|
||||
helpText?: string;
|
||||
className?: string;
|
||||
labelClassName?: string;
|
||||
inputClassName?: string;
|
||||
|
|
@ -17,6 +18,7 @@ const Input: React.FC<InputProps> = ({
|
|||
className,
|
||||
label,
|
||||
name,
|
||||
helpText,
|
||||
labelClassName,
|
||||
inputClassName,
|
||||
containerClassName,
|
||||
|
|
@ -29,10 +31,11 @@ const Input: React.FC<InputProps> = ({
|
|||
<div className={classNames(className)}>
|
||||
<label
|
||||
htmlFor={name}
|
||||
className={classNames("block text-base/6 font-medium text-gray-700", labelClassName)}
|
||||
className={classNames("block text-base/6 font-medium text-text-primary", labelClassName)}
|
||||
>
|
||||
{label}{required ? "*" : ""}
|
||||
</label>
|
||||
{helpText && <p className="mt-1 text-sm text-text-muted">{helpText}</p>}
|
||||
<div className={classNames("mt-1.5", containerClassName)}>
|
||||
<div className="relative">
|
||||
{leftIcon && (
|
||||
|
|
@ -46,7 +49,7 @@ const Input: React.FC<InputProps> = ({
|
|||
placeholder={props.placeholder || label}
|
||||
className={classNames(
|
||||
inputClassName,
|
||||
"block w-full rounded-md bg-white px-3 py-2 text-base text-gray-900 border border-gray-400 placeholder:text-gray-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-primary sm:text-sm/6",
|
||||
"block w-full rounded-md bg-surface-primary px-3 py-2 text-base text-text-primary border border-border-default placeholder:text-text-muted focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-primary sm:text-sm/6",
|
||||
leftIcon ? "pl-10" : "pl-3",
|
||||
error ? "!border-red-500 focus:outline-red-500 !bg-red-100" : ""
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -26,12 +26,12 @@ export default function Switch({
|
|||
{label && (
|
||||
<label
|
||||
htmlFor={switchId}
|
||||
className="text-base font-medium text-gray-900 cursor-pointer"
|
||||
className="text-base font-medium text-text-primary cursor-pointer"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
{description && <p className="text-sm text-gray-500 mt-1">{description}</p>}
|
||||
{description && <p className="text-sm text-text-muted mt-1">{description}</p>}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center ml-4">
|
||||
|
|
@ -45,7 +45,7 @@ export default function Switch({
|
|||
className={clsx(
|
||||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent',
|
||||
'transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-desert-green focus:ring-offset-2',
|
||||
checked ? 'bg-desert-green' : 'bg-gray-200',
|
||||
checked ? 'bg-desert-green' : 'bg-border-default',
|
||||
disabled ? 'opacity-50 cursor-not-allowed' : ''
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -9,11 +9,11 @@ interface BackToHomeHeaderProps {
|
|||
|
||||
export default function BackToHomeHeader({ className, children }: BackToHomeHeaderProps) {
|
||||
return (
|
||||
<div className={classNames('flex border-b border-gray-900/10 p-4', className)}>
|
||||
<div className={classNames('flex border-b border-border-subtle p-4', className)}>
|
||||
<div className="justify-self-start">
|
||||
<Link href="/home" className="flex items-center">
|
||||
<IconArrowLeft className="mr-2" size={24} />
|
||||
<p className="text-lg text-gray-600">Back to Home</p>
|
||||
<p className="text-lg text-text-secondary">Back to Home</p>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex-grow flex flex-col justify-center">{children}</div>
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export default function MapComponent() {
|
|||
width: '100%',
|
||||
height: '100vh',
|
||||
}}
|
||||
mapStyle={`http://${window.location.hostname}:${window.location.port}/api/maps/styles`}
|
||||
mapStyle={`${window.location.protocol}//${window.location.hostname}:${window.location.port}/api/maps/styles`}
|
||||
mapLib={maplibregl}
|
||||
initialViewState={{
|
||||
longitude: -101,
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user