mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-06-24 08:30:46 +00:00
🆔 feat: Built-in Build Metadata for Support Triage (#12756)
* 🏗️ refactor: Derive App Version from Root package.json + Add buildInfo Schema The hardcoded `Constants.VERSION` in `data-provider` is now replaced at rollup build time via `@rollup/plugin-replace`, sourcing from the root `package.json` so version bumps are a single-file change. Adds the shape needed by the rest of the series: - `interface.buildInfo` boolean flag (default `true`) — lets self-hosters opt out of exposing commit/branch/date. - `buildInfo` on `TStartupConfig` — commit/commitShort/branch/buildDate. - `SettingsTabValues.ABOUT` — new settings tab enum value. Ref: https://github.com/danny-avila/LibreChat/issues/12406 * 🛠️ feat: Add Build Metadata Resolver and Expose via /api/config Adds `resolveBuildInfo()` in `@librechat/api` that surfaces commit SHA, branch, and build date from (in order) `BUILD_*` env vars, then local git metadata. Result is cached per-process. `/api/config` includes a `buildInfo` field on both authenticated and anonymous responses when `interface.buildInfo !== false` and at least one resolver field is populated. Omitted entirely otherwise. Designed so pre-built Docker images carry metadata via build-arg while source installs pick it up from `.git` — no manual version tracking. Ref: https://github.com/danny-avila/LibreChat/issues/12406 * ℹ️ feat: Add Settings → About Panel with Diagnostics Copy New Settings tab that renders the running build's version, commit (short SHA), branch, and build date in a monospaced block alongside a "Copy diagnostics" button that emits a preformatted text blob for pasting into support issues. Tab is hidden when `interface.buildInfo` is set to `false`. Reads from `startupConfig.buildInfo` provided by `/api/config`. Ref: https://github.com/danny-avila/LibreChat/issues/12406 * 🐳 ci: Inject BUILD_COMMIT/BRANCH/DATE into Docker Images Adds optional `BUILD_COMMIT`, `BUILD_BRANCH`, `BUILD_DATE` ARGs to both `Dockerfile` and `Dockerfile.multi`, wired as `ENV` vars in the runtime stage so the backend's `resolveBuildInfo` picks them up. All image-publishing workflows (`tag`, `main`, `dev`, `dev-branch`, `dev-staging`) now compute `${github.sha}`, `${github.ref_name}`, and a UTC timestamp, then pass them to `docker/build-push-action` as `build-args`. Defaults are empty — non-CI builds (local `docker build`) still work, and the backend falls back to local `.git` metadata if ARGs aren't set. Ref: https://github.com/danny-avila/LibreChat/issues/12406 * 📝 docs: Direct Bug Reporters to Settings → About for Version Info The previous instructions (`docker images | grep librechat`, `git rev-parse HEAD`) only worked for a subset of deployments and rarely produced a commit SHA for users pulling pre-built images. Point users to the new in-app Settings → About panel's "Copy diagnostics" button, which captures version, commit, branch, build date, and user agent in a single preformatted block. Fallback instructions preserved for older installs. Ref: https://github.com/danny-avila/LibreChat/issues/12406 * 🐳 fix: Move BUILD_* ENV to End of Docker Stages to Preserve Layer Cache Per-commit BUILD_COMMIT/BUILD_DATE changes were being promoted to ENV before `npm ci` / `npm run frontend` (single-stage) and before `npm ci --omit=dev` (multi-stage api-build), which invalidated the cache for every subsequent layer on every CI run. Move the ARG/ENV block below the heavy install and build steps in both Dockerfiles. Metadata is still available in the runtime image but no longer busts layer reuse. Addresses codex review on #12756. * 🔧 fix: Propagate interface.buildInfo=false to Unauthenticated /api/config The unauthenticated branch of `/api/config` was emitting an `interface` object only when `privacyPolicy` or `termsOfService` was set, which meant an admin's explicit `interface.buildInfo: false` opt-out was never visible to anonymous/guest clients. `Settings.tsx` gates the About tab on `startupConfig?.interface?.buildInfo !== false`, so a missing field fell through as "enabled" for those clients. Include `interface.buildInfo: false` in the unauth payload whenever it's explicitly disabled. Keep the implicit default (true) absent to preserve the minimal-unauth-payload convention. Addresses codex review on #12756. * 🔀 ci: Trigger Dev Image Workflows on Root package.json + Dockerfile Changes The baked `Constants.VERSION` now reads from the root `package.json` via rollup-plugin-replace, but the `dev-images.yml` and `dev-branch-images.yml` path filters only matched `api/**`, `client/**`, `packages/**`. A release commit that only bumps root `package.json` would not trigger a rebuild, leaving `latest` dev images with stale Footer/About version metadata. Include `package.json`, `package-lock.json`, and both Dockerfiles in the path filters so dependency changes (lockfile rebuilds) and image build tweaks also rebuild dev images. Addresses codex review on #12756. * 🧽 fix: Harden About Panel Lifecycle, A11y, and Loading Gate Review follow-ups on #12756: - #1 timer leak: stash the copy-state `setTimeout` in a ref and clear it from a `useEffect` cleanup so unmounting the Settings dialog mid-toast doesn't fire `setCopied(false)` on an unmounted component. - #3 flash of About tab: gate `aboutEnabled` on `startupConfig != null` so the tab stays hidden until `/api/config` returns. For admins who disabled `interface.buildInfo`, the tab no longer briefly appears and vanishes on page load. - #6 aria-live placement: move the live region off the interactive button onto a dedicated `<span role="status" aria-live="polite">` so screen readers announce the copied state, not the full button content on every re-render. - #2 missing coverage: add `About.spec.tsx` exercising populated/empty buildInfo rendering, invalid-date handling, diagnostics clipboard payload, copy-state toggling, unmount cleanup, and the live region. * ⚡ perf: Eagerly Resolve Build Info at Module Load Review follow-up #4 on #12756: `resolveBuildInfo()` calls `execFileSync` with a 2s timeout on source installs without `BUILD_*` env vars. Paying this cost on the first HTTP request blocks the event loop mid-flight. Call `resolveBuildInfo()` once at config route module load so the resolver's cache is warm before any request arrives. Docker images with the BUILD_* env vars set sidestep the git path entirely, so this only affects the edge case of source installs. * 📝 docs: Document rollup Version Placeholder Contract Review follow-ups #5 / #8 on #12756. The `__LIBRECHAT_VERSION__` placeholder relies on a substring replacement rule that only works because the token appears inside a string literal, and the substitution only runs during `npm run build:data-provider`. - Expand the `Constants.VERSION` JSDoc to spell out that consumers read the placeholder through the built dist bundle; source-level test imports would see the raw placeholder. - Add a NOTE above the rollup `replace` config warning future contributors not to repurpose the token as a bare identifier without switching to a quoted replacement value. Non-functional; prevents future contributors from stepping on a subtle constraint. * 🪪 fix: Only Toast "Copied" When Clipboard Copy Actually Succeeds Codex R5 on #12756. `copy-to-clipboard` returns a boolean indicating whether the underlying `execCommand('copy')` / fallback prompt actually wrote to the clipboard. The previous handler flipped to the "Copied" state unconditionally, which in hardened browsers or when the permission prompt is dismissed would mislead users into filing bug reports without the diagnostics blob attached. Gate the state/timer/live-region on the boolean return; silently no-op on failure rather than showing a false positive. Adds a test asserting the button label stays at "Copy diagnostics" when the clipboard call fails. * 🐳 fix: Derive main image metadata from checkout * 🪪 fix: Keep About enabled until disabled * ✅ test: Avoid literal Settings mock text * 🧱 refactor: Rename Build Info Module
This commit is contained in:
parent
1746153c17
commit
6d6ea08da4
21 changed files with 852 additions and 18 deletions
21
.github/ISSUE_TEMPLATE/BUG-REPORT.yml
vendored
21
.github/ISSUE_TEMPLATE/BUG-REPORT.yml
vendored
|
|
@ -26,17 +26,14 @@ body:
|
|||
id: version-info
|
||||
attributes:
|
||||
label: Version Information
|
||||
description: |
|
||||
If using Docker, please run and provide the output of:
|
||||
```bash
|
||||
docker images | grep librechat
|
||||
```
|
||||
|
||||
If running from source, please run and provide the output of:
|
||||
```bash
|
||||
git rev-parse HEAD
|
||||
```
|
||||
placeholder: Paste the output here
|
||||
description: |
|
||||
In LibreChat, open **Settings → About** and click **Copy diagnostics**, then paste the result here.
|
||||
This captures the exact version, commit, branch, and build date so maintainers can pinpoint the build you're running.
|
||||
|
||||
If the About panel is unavailable (older version / self-hosted with it disabled), please provide as much of the following as possible instead:
|
||||
- Docker: `docker images | grep librechat` (image tag) and `docker inspect <image> | grep -i "\"Commit\\|BUILD_"` if build args were set
|
||||
- Source: `git rev-parse HEAD` and `git rev-parse --abbrev-ref HEAD`
|
||||
placeholder: Paste the diagnostics block here
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
|
|
@ -93,4 +90,4 @@ body:
|
|||
description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/danny-avila/LibreChat/blob/main/.github/CODE_OF_CONDUCT.md)
|
||||
options:
|
||||
- label: I agree to follow this project's Code of Conduct
|
||||
required: true
|
||||
required: true
|
||||
|
|
|
|||
14
.github/workflows/dev-branch-images.yml
vendored
14
.github/workflows/dev-branch-images.yml
vendored
|
|
@ -9,6 +9,10 @@ on:
|
|||
- 'api/**'
|
||||
- 'client/**'
|
||||
- 'packages/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'Dockerfile'
|
||||
- 'Dockerfile.multi'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
|
@ -65,6 +69,12 @@ jobs:
|
|||
run: |
|
||||
cp .env.example .env
|
||||
|
||||
- name: Compute build metadata
|
||||
run: |
|
||||
echo "BUILD_COMMIT=${{ github.sha }}" >> $GITHUB_ENV
|
||||
echo "BUILD_BRANCH=${{ github.ref_name }}" >> $GITHUB_ENV
|
||||
echo "BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_ENV
|
||||
|
||||
# Build and push Docker images for each target
|
||||
- name: Build and push Docker images
|
||||
uses: docker/build-push-action@v5
|
||||
|
|
@ -79,3 +89,7 @@ jobs:
|
|||
${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:latest
|
||||
platforms: linux/amd64,linux/arm64
|
||||
target: ${{ matrix.target }}
|
||||
build-args: |
|
||||
BUILD_COMMIT=${{ env.BUILD_COMMIT }}
|
||||
BUILD_BRANCH=${{ env.BUILD_BRANCH }}
|
||||
BUILD_DATE=${{ env.BUILD_DATE }}
|
||||
|
|
|
|||
14
.github/workflows/dev-images.yml
vendored
14
.github/workflows/dev-images.yml
vendored
|
|
@ -9,6 +9,10 @@ on:
|
|||
- 'api/**'
|
||||
- 'client/**'
|
||||
- 'packages/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'Dockerfile'
|
||||
- 'Dockerfile.multi'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
|
@ -61,6 +65,12 @@ jobs:
|
|||
run: |
|
||||
cp .env.example .env
|
||||
|
||||
- name: Compute build metadata
|
||||
run: |
|
||||
echo "BUILD_COMMIT=${{ github.sha }}" >> $GITHUB_ENV
|
||||
echo "BUILD_BRANCH=${{ github.ref_name }}" >> $GITHUB_ENV
|
||||
echo "BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_ENV
|
||||
|
||||
# Build and push Docker images for each target
|
||||
- name: Build and push Docker images
|
||||
uses: docker/build-push-action@v5
|
||||
|
|
@ -75,3 +85,7 @@ jobs:
|
|||
${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:latest
|
||||
platforms: linux/amd64,linux/arm64
|
||||
target: ${{ matrix.target }}
|
||||
build-args: |
|
||||
BUILD_COMMIT=${{ env.BUILD_COMMIT }}
|
||||
BUILD_BRANCH=${{ env.BUILD_BRANCH }}
|
||||
BUILD_DATE=${{ env.BUILD_DATE }}
|
||||
|
|
|
|||
10
.github/workflows/dev-staging-images.yml
vendored
10
.github/workflows/dev-staging-images.yml
vendored
|
|
@ -53,6 +53,12 @@ jobs:
|
|||
run: |
|
||||
cp .env.example .env
|
||||
|
||||
- name: Compute build metadata
|
||||
run: |
|
||||
echo "BUILD_COMMIT=${{ github.sha }}" >> $GITHUB_ENV
|
||||
echo "BUILD_BRANCH=${{ github.ref_name }}" >> $GITHUB_ENV
|
||||
echo "BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_ENV
|
||||
|
||||
# Build and push Docker images for each target
|
||||
- name: Build and push Docker images
|
||||
uses: docker/build-push-action@v5
|
||||
|
|
@ -67,3 +73,7 @@ jobs:
|
|||
${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:latest
|
||||
platforms: linux/amd64,linux/arm64
|
||||
target: ${{ matrix.target }}
|
||||
build-args: |
|
||||
BUILD_COMMIT=${{ env.BUILD_COMMIT }}
|
||||
BUILD_BRANCH=${{ env.BUILD_BRANCH }}
|
||||
BUILD_DATE=${{ env.BUILD_DATE }}
|
||||
|
|
|
|||
10
.github/workflows/main-image-workflow.yml
vendored
10
.github/workflows/main-image-workflow.yml
vendored
|
|
@ -38,6 +38,12 @@ jobs:
|
|||
fi
|
||||
printf 'LATEST_TAG=%s\n' "$LATEST_TAG" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Compute build metadata
|
||||
run: |
|
||||
printf 'BUILD_COMMIT=%s\n' "$(git rev-parse HEAD)" >> "$GITHUB_ENV"
|
||||
printf 'BUILD_BRANCH=main\n' >> "$GITHUB_ENV"
|
||||
printf 'BUILD_DATE=%s\n' "$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> "$GITHUB_ENV"
|
||||
|
||||
# Set up QEMU
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
|
@ -80,3 +86,7 @@ jobs:
|
|||
${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:latest
|
||||
platforms: linux/amd64,linux/arm64
|
||||
target: ${{ matrix.target }}
|
||||
build-args: |
|
||||
BUILD_COMMIT=${{ env.BUILD_COMMIT }}
|
||||
BUILD_BRANCH=${{ env.BUILD_BRANCH }}
|
||||
BUILD_DATE=${{ env.BUILD_DATE }}
|
||||
|
|
|
|||
10
.github/workflows/tag-images.yml
vendored
10
.github/workflows/tag-images.yml
vendored
|
|
@ -74,6 +74,12 @@ jobs:
|
|||
run: |
|
||||
cp .env.example .env
|
||||
|
||||
- name: Compute build metadata
|
||||
run: |
|
||||
echo "BUILD_COMMIT=${{ github.sha }}" >> $GITHUB_ENV
|
||||
echo "BUILD_BRANCH=${{ github.ref_name }}" >> $GITHUB_ENV
|
||||
echo "BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_ENV
|
||||
|
||||
- name: Resolve image tags
|
||||
id: image-tags
|
||||
env:
|
||||
|
|
@ -104,3 +110,7 @@ jobs:
|
|||
tags: ${{ steps.image-tags.outputs.tags }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
target: ${{ matrix.target }}
|
||||
build-args: |
|
||||
BUILD_COMMIT=${{ env.BUILD_COMMIT }}
|
||||
BUILD_BRANCH=${{ env.BUILD_BRANCH }}
|
||||
BUILD_DATE=${{ env.BUILD_DATE }}
|
||||
|
|
|
|||
12
Dockerfile
12
Dockerfile
|
|
@ -59,6 +59,18 @@ RUN \
|
|||
npm prune --production; \
|
||||
npm cache clean --force
|
||||
|
||||
# Optional build metadata surfaced in Settings -> About for support triage.
|
||||
# Declared here (after the heavy install/build steps) so that commit/date
|
||||
# changing on every CI run does not bust the cache for dependency install
|
||||
# and frontend build layers. When unset, the backend falls back to local
|
||||
# git resolution (if .git is present), and finally to empty values.
|
||||
ARG BUILD_COMMIT=
|
||||
ARG BUILD_BRANCH=
|
||||
ARG BUILD_DATE=
|
||||
ENV BUILD_COMMIT=${BUILD_COMMIT}
|
||||
ENV BUILD_BRANCH=${BUILD_BRANCH}
|
||||
ENV BUILD_DATE=${BUILD_DATE}
|
||||
|
||||
# Node API setup
|
||||
EXPOSE 3080
|
||||
ENV HOST=0.0.0.0
|
||||
|
|
|
|||
|
|
@ -4,6 +4,11 @@
|
|||
# Set configurable max-old-space-size with default
|
||||
ARG NODE_MAX_OLD_SPACE_SIZE=6144
|
||||
|
||||
# Optional build metadata surfaced in Settings -> About for support triage.
|
||||
ARG BUILD_COMMIT=
|
||||
ARG BUILD_BRANCH=
|
||||
ARG BUILD_DATE=
|
||||
|
||||
# Base for all builds
|
||||
FROM node:20-alpine AS base-min
|
||||
ARG NPM_CI_TIMEOUT_SECONDS=1500
|
||||
|
|
@ -108,6 +113,15 @@ COPY --from=data-provider-build /app/packages/data-provider/dist ./packages/data
|
|||
COPY --from=data-schemas-build /app/packages/data-schemas/dist ./packages/data-schemas/dist
|
||||
COPY --from=api-package-build /app/packages/api/dist ./packages/api/dist
|
||||
COPY --from=client-build /app/client/dist ./client/dist
|
||||
# Propagate build metadata into runtime env so /api/config can expose it.
|
||||
# Declared here (after the heavy install/copy steps) so that commit/date
|
||||
# changing on every CI run does not bust the cache for those layers.
|
||||
ARG BUILD_COMMIT
|
||||
ARG BUILD_BRANCH
|
||||
ARG BUILD_DATE
|
||||
ENV BUILD_COMMIT=${BUILD_COMMIT}
|
||||
ENV BUILD_BRANCH=${BUILD_BRANCH}
|
||||
ENV BUILD_DATE=${BUILD_DATE}
|
||||
WORKDIR /app/api
|
||||
EXPOSE 3080
|
||||
ENV HOST=0.0.0.0
|
||||
|
|
|
|||
|
|
@ -21,9 +21,16 @@ jest.mock('@librechat/data-schemas', () => ({
|
|||
}));
|
||||
|
||||
const mockGetCloudFrontConfig = jest.fn(() => null);
|
||||
const mockResolveBuildInfo = jest.fn(() => ({
|
||||
commit: null,
|
||||
commitShort: null,
|
||||
branch: null,
|
||||
buildDate: null,
|
||||
}));
|
||||
jest.mock('@librechat/api', () => ({
|
||||
...jest.requireActual('@librechat/api'),
|
||||
getCloudFrontConfig: (...args) => mockGetCloudFrontConfig(...args),
|
||||
resolveBuildInfo: (...args) => mockResolveBuildInfo(...args),
|
||||
}));
|
||||
|
||||
const request = require('supertest');
|
||||
|
|
@ -63,6 +70,12 @@ const mockUser = {
|
|||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
mockResolveBuildInfo.mockReturnValue({
|
||||
commit: null,
|
||||
commitShort: null,
|
||||
branch: null,
|
||||
buildDate: null,
|
||||
});
|
||||
delete process.env.APP_TITLE;
|
||||
delete process.env.CHECK_BALANCE;
|
||||
delete process.env.START_BALANCE;
|
||||
|
|
@ -446,4 +459,112 @@ describe('GET /api/config', () => {
|
|||
expect(response.body).toHaveProperty('error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildInfo payload', () => {
|
||||
const populatedBuildInfo = {
|
||||
commit: 'abcdef1234567890abcdef1234567890abcdef12',
|
||||
commitShort: 'abcdef1',
|
||||
branch: 'dev',
|
||||
buildDate: '2026-04-20T12:00:00Z',
|
||||
};
|
||||
|
||||
it('includes buildInfo in authenticated response when interface flag is not explicitly disabled', async () => {
|
||||
mockGetAppConfig.mockResolvedValue(baseAppConfig);
|
||||
mockResolveBuildInfo.mockReturnValue(populatedBuildInfo);
|
||||
const app = createApp(mockUser);
|
||||
|
||||
const response = await request(app).get('/api/config');
|
||||
|
||||
expect(response.body.buildInfo).toEqual(populatedBuildInfo);
|
||||
});
|
||||
|
||||
it('omits buildInfo when interface.buildInfo is false', async () => {
|
||||
mockGetAppConfig.mockResolvedValue({
|
||||
...baseAppConfig,
|
||||
interfaceConfig: { ...baseAppConfig.interfaceConfig, buildInfo: false },
|
||||
});
|
||||
mockResolveBuildInfo.mockReturnValue(populatedBuildInfo);
|
||||
const app = createApp(mockUser);
|
||||
|
||||
const response = await request(app).get('/api/config');
|
||||
|
||||
expect(response.body).not.toHaveProperty('buildInfo');
|
||||
});
|
||||
|
||||
it('omits buildInfo when all resolver fields are null', async () => {
|
||||
mockGetAppConfig.mockResolvedValue(baseAppConfig);
|
||||
mockResolveBuildInfo.mockReturnValue({
|
||||
commit: null,
|
||||
commitShort: null,
|
||||
branch: null,
|
||||
buildDate: null,
|
||||
});
|
||||
const app = createApp(mockUser);
|
||||
|
||||
const response = await request(app).get('/api/config');
|
||||
|
||||
expect(response.body).not.toHaveProperty('buildInfo');
|
||||
});
|
||||
|
||||
it('includes buildInfo in unauthenticated response when flag is not disabled', async () => {
|
||||
mockGetAppConfig.mockResolvedValue(baseAppConfig);
|
||||
mockResolveBuildInfo.mockReturnValue(populatedBuildInfo);
|
||||
const app = createApp(null);
|
||||
|
||||
const response = await request(app).get('/api/config');
|
||||
|
||||
expect(response.body.buildInfo).toEqual(populatedBuildInfo);
|
||||
});
|
||||
|
||||
it('omits buildInfo in unauthenticated response when interface.buildInfo is false', async () => {
|
||||
mockGetAppConfig.mockResolvedValue({
|
||||
...baseAppConfig,
|
||||
interfaceConfig: { ...baseAppConfig.interfaceConfig, buildInfo: false },
|
||||
});
|
||||
mockResolveBuildInfo.mockReturnValue(populatedBuildInfo);
|
||||
const app = createApp(null);
|
||||
|
||||
const response = await request(app).get('/api/config');
|
||||
|
||||
expect(response.body).not.toHaveProperty('buildInfo');
|
||||
});
|
||||
|
||||
it('propagates interface.buildInfo=false in unauthenticated response so clients can hide About tab', async () => {
|
||||
mockGetAppConfig.mockResolvedValue({
|
||||
...baseAppConfig,
|
||||
interfaceConfig: { ...baseAppConfig.interfaceConfig, buildInfo: false },
|
||||
});
|
||||
const app = createApp(null);
|
||||
|
||||
const response = await request(app).get('/api/config');
|
||||
|
||||
expect(response.body.interface).toBeDefined();
|
||||
expect(response.body.interface.buildInfo).toBe(false);
|
||||
});
|
||||
|
||||
it('does not add interface.buildInfo=true to unauthenticated response (default stays implicit)', async () => {
|
||||
mockGetAppConfig.mockResolvedValue({
|
||||
...baseAppConfig,
|
||||
interfaceConfig: { privacyPolicy: { externalUrl: 'https://x' }, buildInfo: true },
|
||||
});
|
||||
const app = createApp(null);
|
||||
|
||||
const response = await request(app).get('/api/config');
|
||||
|
||||
expect(response.body.interface).toBeDefined();
|
||||
expect(response.body.interface).not.toHaveProperty('buildInfo');
|
||||
});
|
||||
|
||||
it('includes interface block with only buildInfo=false when nothing else is set', async () => {
|
||||
mockGetAppConfig.mockResolvedValue({
|
||||
...baseAppConfig,
|
||||
interfaceConfig: { buildInfo: false },
|
||||
});
|
||||
const app = createApp(null);
|
||||
|
||||
const response = await request(app).get('/api/config');
|
||||
|
||||
expect(response.body.interface).toEqual({ buildInfo: false });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ const {
|
|||
isEnabled,
|
||||
getBalanceConfig,
|
||||
getCloudFrontConfig,
|
||||
resolveBuildInfo,
|
||||
sanitizeModelSpecs,
|
||||
} = require('@librechat/api');
|
||||
const { defaultSocialLogins } = require('librechat-data-provider');
|
||||
|
|
@ -25,6 +26,13 @@ const publicSharedLinksEnabled =
|
|||
const sharePointFilePickerEnabled = isEnabled(process.env.ENABLE_SHAREPOINT_FILEPICKER);
|
||||
const openidReuseTokens = isEnabled(process.env.OPENID_REUSE_TOKENS);
|
||||
|
||||
/**
|
||||
* Resolve build metadata eagerly at module load so the first `/api/config`
|
||||
* request does not pay the cost of `execFileSync('git', ...)` on the hot path.
|
||||
* The resolver caches its result after the first call.
|
||||
*/
|
||||
resolveBuildInfo();
|
||||
|
||||
function isBirthday() {
|
||||
const today = new Date();
|
||||
return today.getMonth() === 1 && today.getDate() === 11;
|
||||
|
|
@ -105,6 +113,22 @@ function buildSharedPayload() {
|
|||
return payload;
|
||||
}
|
||||
|
||||
function buildBuildInfoPayload(interfaceConfig) {
|
||||
if (interfaceConfig?.buildInfo === false) {
|
||||
return undefined;
|
||||
}
|
||||
const info = resolveBuildInfo();
|
||||
if (!info.commit && !info.branch && !info.buildDate) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
commit: info.commit,
|
||||
commitShort: info.commitShort,
|
||||
branch: info.branch,
|
||||
buildDate: info.buildDate,
|
||||
};
|
||||
}
|
||||
|
||||
function buildWebSearchConfig(appConfig) {
|
||||
const ws = appConfig?.webSearch;
|
||||
if (!ws) {
|
||||
|
|
@ -159,7 +183,8 @@ router.get('/', async function (req, res) {
|
|||
};
|
||||
|
||||
const interfaceConfig = baseConfig?.interfaceConfig;
|
||||
if (interfaceConfig?.privacyPolicy || interfaceConfig?.termsOfService) {
|
||||
const buildInfoDisabled = interfaceConfig?.buildInfo === false;
|
||||
if (interfaceConfig?.privacyPolicy || interfaceConfig?.termsOfService || buildInfoDisabled) {
|
||||
payload.interface = {};
|
||||
if (interfaceConfig.privacyPolicy) {
|
||||
payload.interface.privacyPolicy = interfaceConfig.privacyPolicy;
|
||||
|
|
@ -167,6 +192,14 @@ router.get('/', async function (req, res) {
|
|||
if (interfaceConfig.termsOfService) {
|
||||
payload.interface.termsOfService = interfaceConfig.termsOfService;
|
||||
}
|
||||
if (buildInfoDisabled) {
|
||||
payload.interface.buildInfo = false;
|
||||
}
|
||||
}
|
||||
|
||||
const unauthBuildInfo = buildBuildInfoPayload(interfaceConfig);
|
||||
if (unauthBuildInfo) {
|
||||
payload.buildInfo = unauthBuildInfo;
|
||||
}
|
||||
|
||||
return res.status(200).send(payload);
|
||||
|
|
@ -205,6 +238,11 @@ router.get('/', async function (req, res) {
|
|||
payload.webSearch = webSearch;
|
||||
}
|
||||
|
||||
const buildInfo = buildBuildInfoPayload(appConfig?.interfaceConfig);
|
||||
if (buildInfo) {
|
||||
payload.buildInfo = buildInfo;
|
||||
}
|
||||
|
||||
if (!payload.allowAccountDeletion) {
|
||||
try {
|
||||
const userId = req.user.id ?? req.user._id?.toString();
|
||||
|
|
|
|||
85
client/src/components/Nav/Settings.spec.tsx
Normal file
85
client/src/components/Nav/Settings.spec.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import React from 'react';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import Settings from './Settings';
|
||||
|
||||
const mockUseGetStartupConfig = jest.fn();
|
||||
|
||||
jest.mock('~/data-provider', () => ({
|
||||
useGetStartupConfig: () => mockUseGetStartupConfig(),
|
||||
}));
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
jest.mock('~/hooks/usePersonalizationAccess', () => ({
|
||||
__esModule: true,
|
||||
default: () => ({
|
||||
hasMemoryOptOut: false,
|
||||
hasAnyPersonalizationFeature: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/client', () => ({
|
||||
GearIcon: () => <span aria-hidden="true" />,
|
||||
DataIcon: () => <span aria-hidden="true" />,
|
||||
UserIcon: () => <span aria-hidden="true" />,
|
||||
SpeechIcon: () => <span aria-hidden="true" />,
|
||||
PersonalizationIcon: () => <span aria-hidden="true" />,
|
||||
useMediaQuery: () => false,
|
||||
}));
|
||||
|
||||
jest.mock('./SettingsTabs', () => ({
|
||||
General: () => <div data-testid="general-panel" />,
|
||||
Chat: () => <div data-testid="chat-panel" />,
|
||||
Commands: () => <div data-testid="commands-panel" />,
|
||||
Speech: () => <div data-testid="speech-panel" />,
|
||||
Personalization: () => <div data-testid="personalization-panel" />,
|
||||
Data: () => <div data-testid="data-panel" />,
|
||||
Balance: () => <div data-testid="balance-panel" />,
|
||||
Account: () => <div data-testid="account-panel" />,
|
||||
About: () => <div data-testid="about-panel" />,
|
||||
}));
|
||||
|
||||
function renderSettings() {
|
||||
return render(<Settings open={true} onOpenChange={jest.fn()} />);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockUseGetStartupConfig.mockReturnValue({ data: {} });
|
||||
});
|
||||
|
||||
describe('Settings', () => {
|
||||
it('shows the About tab while startup config is loading', () => {
|
||||
mockUseGetStartupConfig.mockReturnValue({ data: undefined });
|
||||
|
||||
renderSettings();
|
||||
|
||||
expect(screen.getByText('com_nav_setting_about')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the About tab only when buildInfo is explicitly disabled', () => {
|
||||
mockUseGetStartupConfig.mockReturnValue({ data: { interface: { buildInfo: false } } });
|
||||
|
||||
renderSettings();
|
||||
|
||||
expect(screen.queryByText('com_nav_setting_about')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('resets the active tab when loaded config disables About', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = renderSettings();
|
||||
|
||||
await user.click(screen.getByText('com_nav_setting_about'));
|
||||
expect(screen.getByTestId('about-panel')).toBeInTheDocument();
|
||||
|
||||
mockUseGetStartupConfig.mockReturnValue({ data: { interface: { buildInfo: false } } });
|
||||
rerender(<Settings open={true} onOpenChange={jest.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('about-panel')).not.toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByTestId('general-panel')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import React, { useState, useRef } from 'react';
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import * as Tabs from '@radix-ui/react-tabs';
|
||||
import { SettingsTabValues } from 'librechat-data-provider';
|
||||
import { MessageSquare, Command, DollarSign } from 'lucide-react';
|
||||
import { MessageSquare, Command, DollarSign, Info } from 'lucide-react';
|
||||
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react';
|
||||
import {
|
||||
GearIcon,
|
||||
|
|
@ -21,6 +21,7 @@ import {
|
|||
Data,
|
||||
Balance,
|
||||
Account,
|
||||
About,
|
||||
} from './SettingsTabs';
|
||||
import usePersonalizationAccess from '~/hooks/usePersonalizationAccess';
|
||||
import { useLocalize, TranslationKeys } from '~/hooks';
|
||||
|
|
@ -34,6 +35,13 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
|
|||
const [activeTab, setActiveTab] = useState(SettingsTabValues.GENERAL);
|
||||
const tabRefs = useRef({});
|
||||
const { hasAnyPersonalizationFeature, hasMemoryOptOut } = usePersonalizationAccess();
|
||||
const aboutEnabled = startupConfig?.interface?.buildInfo !== false;
|
||||
|
||||
useEffect(() => {
|
||||
if (!aboutEnabled && activeTab === SettingsTabValues.ABOUT) {
|
||||
setActiveTab(SettingsTabValues.GENERAL);
|
||||
}
|
||||
}, [aboutEnabled, activeTab]);
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
const tabs: SettingsTabValues[] = [
|
||||
|
|
@ -45,6 +53,7 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
|
|||
SettingsTabValues.DATA,
|
||||
...(startupConfig?.balance?.enabled ? [SettingsTabValues.BALANCE] : []),
|
||||
SettingsTabValues.ACCOUNT,
|
||||
...(aboutEnabled ? [SettingsTabValues.ABOUT] : []),
|
||||
];
|
||||
const currentIndex = tabs.indexOf(activeTab);
|
||||
|
||||
|
|
@ -121,6 +130,15 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
|
|||
icon: <UserIcon />,
|
||||
label: 'com_nav_setting_account',
|
||||
},
|
||||
...(aboutEnabled
|
||||
? [
|
||||
{
|
||||
value: SettingsTabValues.ABOUT,
|
||||
icon: <Info className="icon-sm" aria-hidden="true" />,
|
||||
label: 'com_nav_setting_about' as TranslationKeys,
|
||||
},
|
||||
]
|
||||
: ([] as { value: SettingsTabValues; icon: React.JSX.Element; label: TranslationKeys }[])),
|
||||
];
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
|
|
@ -251,6 +269,11 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
|
|||
<Tabs.Content value={SettingsTabValues.ACCOUNT} tabIndex={-1}>
|
||||
<Account />
|
||||
</Tabs.Content>
|
||||
{aboutEnabled && (
|
||||
<Tabs.Content value={SettingsTabValues.ABOUT} tabIndex={-1}>
|
||||
<About />
|
||||
</Tabs.Content>
|
||||
)}
|
||||
</div>
|
||||
</Tabs.Root>
|
||||
</div>
|
||||
|
|
|
|||
170
client/src/components/Nav/SettingsTabs/About/About.spec.tsx
Normal file
170
client/src/components/Nav/SettingsTabs/About/About.spec.tsx
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import React from 'react';
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { Constants } from 'librechat-data-provider';
|
||||
import type { TStartupConfig } from 'librechat-data-provider';
|
||||
import About from './About';
|
||||
|
||||
const mockCopy = jest.fn<boolean, unknown[]>();
|
||||
jest.mock('copy-to-clipboard', () => ({
|
||||
__esModule: true,
|
||||
default: (...args: unknown[]) => mockCopy(...args),
|
||||
}));
|
||||
|
||||
const mockUseGetStartupConfig = jest.fn();
|
||||
jest.mock('~/data-provider', () => ({
|
||||
useGetStartupConfig: () => mockUseGetStartupConfig(),
|
||||
}));
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
const populatedBuildInfo: NonNullable<TStartupConfig['buildInfo']> = {
|
||||
commit: 'abcdef1234567890abcdef1234567890abcdef12',
|
||||
commitShort: 'abcdef1',
|
||||
branch: 'dev',
|
||||
buildDate: '2026-04-20T12:00:00Z',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockCopy.mockReset();
|
||||
mockCopy.mockReturnValue(true);
|
||||
mockUseGetStartupConfig.mockReturnValue({ data: { buildInfo: populatedBuildInfo } });
|
||||
});
|
||||
|
||||
describe('About', () => {
|
||||
describe('rendering', () => {
|
||||
it('renders version, commit (short), branch, and build date when buildInfo is populated', () => {
|
||||
render(<About />);
|
||||
|
||||
expect(screen.getByText(Constants.VERSION as string)).toBeInTheDocument();
|
||||
expect(screen.getByText('abcdef1')).toBeInTheDocument();
|
||||
expect(screen.getByText('dev')).toBeInTheDocument();
|
||||
expect(screen.getByText('2026-04-20 12:00:00 UTC')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders em-dash placeholders when buildInfo is missing', () => {
|
||||
mockUseGetStartupConfig.mockReturnValue({ data: {} });
|
||||
render(<About />);
|
||||
|
||||
// version still populated from Constants.VERSION; the other three rows fall back to placeholder
|
||||
const placeholders = screen.getAllByText('—');
|
||||
expect(placeholders.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('renders em-dash placeholders when startupConfig is undefined (initial load)', () => {
|
||||
mockUseGetStartupConfig.mockReturnValue({ data: undefined });
|
||||
render(<About />);
|
||||
|
||||
const placeholders = screen.getAllByText('—');
|
||||
expect(placeholders.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('shows the raw string when buildDate is not a valid ISO', () => {
|
||||
mockUseGetStartupConfig.mockReturnValue({
|
||||
data: { buildInfo: { ...populatedBuildInfo, buildDate: 'not-a-date' } },
|
||||
});
|
||||
render(<About />);
|
||||
expect(screen.getByText('not-a-date')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('copy diagnostics', () => {
|
||||
it('writes a preformatted diagnostics blob to the clipboard on click', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<About />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /com_nav_about_diagnostics_copy/i }));
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledTimes(1);
|
||||
const [blob, options] = mockCopy.mock.calls[0] as [string, { format: string }];
|
||||
expect(options).toEqual({ format: 'text/plain' });
|
||||
expect(blob).toContain(`LibreChat version: ${Constants.VERSION}`);
|
||||
expect(blob).toContain(`Commit: ${populatedBuildInfo.commit}`);
|
||||
expect(blob).toContain(`Branch: ${populatedBuildInfo.branch}`);
|
||||
expect(blob).toContain('Build date: 2026-04-20 12:00:00 UTC');
|
||||
expect(blob).toContain('User agent: ');
|
||||
});
|
||||
|
||||
it('writes em-dash placeholders into the blob when buildInfo is missing', async () => {
|
||||
mockUseGetStartupConfig.mockReturnValue({ data: {} });
|
||||
const user = userEvent.setup();
|
||||
render(<About />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /com_nav_about_diagnostics_copy/i }));
|
||||
|
||||
const [blob] = mockCopy.mock.calls[0] as [string];
|
||||
expect(blob).toContain('Commit: —');
|
||||
expect(blob).toContain('Branch: —');
|
||||
expect(blob).toContain('Build date: —');
|
||||
});
|
||||
|
||||
it('does not flip to "Copied" when copy-to-clipboard reports failure', async () => {
|
||||
mockCopy.mockReturnValue(false);
|
||||
const user = userEvent.setup();
|
||||
render(<About />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /com_nav_about_diagnostics_copy/i }));
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /com_nav_about_diagnostics_copy/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /com_nav_about_diagnostics_copied/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('status').textContent).toBe('');
|
||||
});
|
||||
|
||||
it('toggles the button label to "Copied" after click and resets after the timer', async () => {
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
|
||||
render(<About />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /com_nav_about_diagnostics_copy/i }));
|
||||
expect(
|
||||
screen.getByRole('button', { name: /com_nav_about_diagnostics_copied/i }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(2000);
|
||||
});
|
||||
expect(
|
||||
screen.getByRole('button', { name: /com_nav_about_diagnostics_copy/i }),
|
||||
).toBeInTheDocument();
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('clears the pending reset timer on unmount (no state update on unmounted component)', async () => {
|
||||
jest.useFakeTimers();
|
||||
const clearSpy = jest.spyOn(globalThis, 'clearTimeout');
|
||||
try {
|
||||
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
|
||||
const { unmount } = render(<About />);
|
||||
await user.click(screen.getByRole('button', { name: /com_nav_about_diagnostics_copy/i }));
|
||||
clearSpy.mockClear();
|
||||
unmount();
|
||||
expect(clearSpy).toHaveBeenCalled();
|
||||
} finally {
|
||||
clearSpy.mockRestore();
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('status live region', () => {
|
||||
it('announces the copied state via a dedicated sr-only live region', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<About />);
|
||||
|
||||
const status = screen.getByRole('status');
|
||||
expect(status.textContent).toBe('');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /com_nav_about_diagnostics_copy/i }));
|
||||
expect(status.textContent).toMatch(/com_nav_about_diagnostics_copied/);
|
||||
});
|
||||
});
|
||||
});
|
||||
138
client/src/components/Nav/SettingsTabs/About/About.tsx
Normal file
138
client/src/components/Nav/SettingsTabs/About/About.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import copy from 'copy-to-clipboard';
|
||||
import { Check, Copy } from 'lucide-react';
|
||||
import { Constants } from 'librechat-data-provider';
|
||||
import type { TStartupConfig } from 'librechat-data-provider';
|
||||
import { useGetStartupConfig } from '~/data-provider';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
const UNKNOWN_PLACEHOLDER = '—';
|
||||
|
||||
function formatBuildDate(raw: string | null | undefined): string {
|
||||
if (!raw) {
|
||||
return UNKNOWN_PLACEHOLDER;
|
||||
}
|
||||
const date = new Date(raw);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return raw;
|
||||
}
|
||||
return date
|
||||
.toISOString()
|
||||
.replace('T', ' ')
|
||||
.replace(/\.\d{3}Z$/, ' UTC');
|
||||
}
|
||||
|
||||
function buildDiagnosticsBlob(
|
||||
version: string,
|
||||
buildInfo: TStartupConfig['buildInfo'] | undefined,
|
||||
): string {
|
||||
const lines: string[] = [
|
||||
`LibreChat version: ${version}`,
|
||||
`Commit: ${buildInfo?.commit ?? UNKNOWN_PLACEHOLDER}`,
|
||||
`Branch: ${buildInfo?.branch ?? UNKNOWN_PLACEHOLDER}`,
|
||||
`Build date: ${formatBuildDate(buildInfo?.buildDate)}`,
|
||||
`User agent: ${typeof navigator !== 'undefined' ? navigator.userAgent : UNKNOWN_PLACEHOLDER}`,
|
||||
];
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-4 py-1.5">
|
||||
<div className="text-text-secondary">{label}</div>
|
||||
<div className="break-all text-right font-mono text-xs text-text-primary">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function About() {
|
||||
const localize = useLocalize();
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copyResetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const buildInfo = startupConfig?.buildInfo;
|
||||
const version: string = Constants.VERSION;
|
||||
|
||||
const diagnosticsBlob = useMemo(
|
||||
() => buildDiagnosticsBlob(version, buildInfo),
|
||||
[version, buildInfo],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (copyResetTimerRef.current) {
|
||||
clearTimeout(copyResetTimerRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const onCopy = useCallback(() => {
|
||||
const succeeded = copy(diagnosticsBlob, { format: 'text/plain' });
|
||||
if (!succeeded) {
|
||||
return;
|
||||
}
|
||||
setCopied(true);
|
||||
if (copyResetTimerRef.current) {
|
||||
clearTimeout(copyResetTimerRef.current);
|
||||
}
|
||||
copyResetTimerRef.current = setTimeout(() => setCopied(false), 2000);
|
||||
}, [diagnosticsBlob]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-1 text-sm text-text-primary">
|
||||
<section aria-labelledby="about-version-heading" className="flex flex-col">
|
||||
<h3 id="about-version-heading" className="mb-2 text-sm font-medium text-text-primary">
|
||||
{localize('com_nav_about_version_heading')}
|
||||
</h3>
|
||||
<div className="rounded-lg border border-border-light bg-surface-secondary p-3">
|
||||
<Row label={localize('com_nav_about_version')} value={version} />
|
||||
<Row
|
||||
label={localize('com_nav_about_commit')}
|
||||
value={buildInfo?.commitShort ?? UNKNOWN_PLACEHOLDER}
|
||||
/>
|
||||
<Row
|
||||
label={localize('com_nav_about_branch')}
|
||||
value={buildInfo?.branch ?? UNKNOWN_PLACEHOLDER}
|
||||
/>
|
||||
<Row
|
||||
label={localize('com_nav_about_build_date')}
|
||||
value={formatBuildDate(buildInfo?.buildDate)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="about-diagnostics-heading" className="flex flex-col">
|
||||
<h3 id="about-diagnostics-heading" className="mb-2 text-sm font-medium text-text-primary">
|
||||
{localize('com_nav_about_diagnostics_heading')}
|
||||
</h3>
|
||||
<p className="mb-2 text-xs text-text-secondary">
|
||||
{localize('com_nav_about_diagnostics_description')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCopy}
|
||||
className="inline-flex items-center justify-center gap-2 self-start rounded-md border border-border-light bg-surface-secondary px-3 py-1.5 text-xs font-medium text-text-primary transition-colors hover:bg-surface-tertiary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-xheavy"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="h-4 w-4" aria-hidden="true" />
|
||||
{localize('com_nav_about_diagnostics_copied')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-4 w-4" aria-hidden="true" />
|
||||
{localize('com_nav_about_diagnostics_copy')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<span className="sr-only" role="status" aria-live="polite" aria-atomic="true">
|
||||
{copied ? localize('com_nav_about_diagnostics_copied') : ''}
|
||||
</span>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(About);
|
||||
|
|
@ -6,3 +6,4 @@ export { default as General } from './General/General';
|
|||
export { default as Account } from './Account/Account';
|
||||
export { default as Commands } from './Commands/Commands';
|
||||
export { default as Personalization } from './Personalization';
|
||||
export { default as About } from './About/About';
|
||||
|
|
|
|||
|
|
@ -577,6 +577,16 @@
|
|||
"com_nav_scroll_button": "Scroll to the end button",
|
||||
"com_nav_search_placeholder": "Search messages",
|
||||
"com_nav_send_message": "Send message",
|
||||
"com_nav_about_branch": "Branch",
|
||||
"com_nav_about_build_date": "Built",
|
||||
"com_nav_about_commit": "Commit",
|
||||
"com_nav_about_diagnostics_copied": "Copied to clipboard",
|
||||
"com_nav_about_diagnostics_copy": "Copy diagnostics",
|
||||
"com_nav_about_diagnostics_description": "Copy this block when opening a support issue so maintainers can identify the exact build you're running.",
|
||||
"com_nav_about_diagnostics_heading": "Diagnostics",
|
||||
"com_nav_about_version": "Version",
|
||||
"com_nav_about_version_heading": "Build information",
|
||||
"com_nav_setting_about": "About",
|
||||
"com_nav_setting_account": "Account",
|
||||
"com_nav_setting_balance": "Balance",
|
||||
"com_nav_setting_chat": "Chat",
|
||||
|
|
|
|||
64
packages/api/src/app/build.spec.ts
Normal file
64
packages/api/src/app/build.spec.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { resolveBuildInfo, __resetBuildInfoCacheForTests } from './build';
|
||||
|
||||
describe('resolveBuildInfo', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
__resetBuildInfoCacheForTests();
|
||||
delete process.env.BUILD_COMMIT;
|
||||
delete process.env.BUILD_BRANCH;
|
||||
delete process.env.BUILD_DATE;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it('prefers BUILD_* env vars over git', () => {
|
||||
process.env.BUILD_COMMIT = 'abcdef1234567890abcdef1234567890abcdef12';
|
||||
process.env.BUILD_BRANCH = 'release/v1.0';
|
||||
process.env.BUILD_DATE = '2026-04-20T12:00:00Z';
|
||||
|
||||
const info = resolveBuildInfo();
|
||||
|
||||
expect(info.commit).toBe('abcdef1234567890abcdef1234567890abcdef12');
|
||||
expect(info.commitShort).toBe('abcdef1');
|
||||
expect(info.branch).toBe('release/v1.0');
|
||||
expect(info.buildDate).toBe('2026-04-20T12:00:00Z');
|
||||
});
|
||||
|
||||
it('returns null for any field that cannot be resolved', () => {
|
||||
__resetBuildInfoCacheForTests();
|
||||
const info = resolveBuildInfo();
|
||||
// commit/branch may or may not resolve depending on whether the test env has git —
|
||||
// but buildDate has no fallback, so it must be null here.
|
||||
expect(info.buildDate).toBeNull();
|
||||
});
|
||||
|
||||
it('treats empty/whitespace env vars as unset', () => {
|
||||
process.env.BUILD_COMMIT = ' ';
|
||||
process.env.BUILD_BRANCH = '';
|
||||
process.env.BUILD_DATE = ' ';
|
||||
|
||||
const info = resolveBuildInfo();
|
||||
|
||||
expect(info.buildDate).toBeNull();
|
||||
// commit/branch will fall back to git or null — can't assert exact value, just that empty env was ignored
|
||||
expect(info.commit).not.toBe(' ');
|
||||
expect(info.branch).not.toBe('');
|
||||
});
|
||||
|
||||
it('normalizes detached-HEAD branch to null', () => {
|
||||
process.env.BUILD_BRANCH = 'HEAD';
|
||||
const info = resolveBuildInfo();
|
||||
expect(info.branch).toBeNull();
|
||||
});
|
||||
|
||||
it('caches result across calls', () => {
|
||||
process.env.BUILD_COMMIT = 'cached1234567890cached1234567890cached12';
|
||||
const first = resolveBuildInfo();
|
||||
process.env.BUILD_COMMIT = 'different1234567890different1234567890de';
|
||||
const second = resolveBuildInfo();
|
||||
expect(second.commit).toBe(first.commit);
|
||||
});
|
||||
});
|
||||
71
packages/api/src/app/build.ts
Normal file
71
packages/api/src/app/build.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { execFileSync } from 'child_process';
|
||||
import { logger } from '@librechat/data-schemas';
|
||||
|
||||
export interface BuildInfo {
|
||||
commit: string | null;
|
||||
commitShort: string | null;
|
||||
branch: string | null;
|
||||
buildDate: string | null;
|
||||
}
|
||||
|
||||
let cached: BuildInfo | null = null;
|
||||
|
||||
function safeGit(args: string[]): string | null {
|
||||
try {
|
||||
const out = execFileSync('git', args, {
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
timeout: 2000,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
const trimmed = out.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalize(value: string | undefined): string | null {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the current deployment's build info from (in order): explicit env vars,
|
||||
* then local git metadata. Used by `/api/config` to expose commit/branch to clients
|
||||
* when `interface.buildInfo` is enabled.
|
||||
*/
|
||||
export function resolveBuildInfo(): BuildInfo {
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const commit = normalize(process.env.BUILD_COMMIT) ?? safeGit(['rev-parse', 'HEAD']);
|
||||
const branch =
|
||||
normalize(process.env.BUILD_BRANCH) ?? safeGit(['rev-parse', '--abbrev-ref', 'HEAD']);
|
||||
const buildDate = normalize(process.env.BUILD_DATE);
|
||||
|
||||
const info: BuildInfo = {
|
||||
commit,
|
||||
commitShort: commit ? commit.slice(0, 7) : null,
|
||||
branch: branch === 'HEAD' ? null : branch,
|
||||
buildDate,
|
||||
};
|
||||
|
||||
cached = info;
|
||||
|
||||
if (!info.commit && !info.branch && !info.buildDate) {
|
||||
logger.debug(
|
||||
'[buildInfo] no BUILD_* env vars set and git metadata unavailable; buildInfo will be empty',
|
||||
);
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
/** Test hook — resets the in-process cache. Not exported from the package barrel. */
|
||||
export function __resetBuildInfoCacheForTests(): void {
|
||||
cached = null;
|
||||
}
|
||||
|
|
@ -6,3 +6,5 @@ export * from './cdn';
|
|||
export * from './checks';
|
||||
export * from './resolve';
|
||||
export * from './shutdown';
|
||||
export { resolveBuildInfo } from './build';
|
||||
export type { BuildInfo } from './build';
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import typescript from 'rollup-plugin-typescript2';
|
||||
import resolve from '@rollup/plugin-node-resolve';
|
||||
import pkg from './package.json';
|
||||
import rootPkg from '../../package.json';
|
||||
import peerDepsExternal from 'rollup-plugin-peer-deps-external';
|
||||
import commonjs from '@rollup/plugin-commonjs';
|
||||
import replace from '@rollup/plugin-replace';
|
||||
|
|
@ -9,8 +10,17 @@ import terser from '@rollup/plugin-terser';
|
|||
const plugins = [
|
||||
peerDepsExternal(),
|
||||
resolve(),
|
||||
// NOTE: `__LIBRECHAT_VERSION__` is only ever used inside a string literal
|
||||
// (e.g. `VERSION = '__LIBRECHAT_VERSION__'` in `src/config.ts`), so substring
|
||||
// replacement produces valid JS without needing `JSON.stringify`. Do not
|
||||
// repurpose this token as a bare identifier without switching to a quoted
|
||||
// replacement value.
|
||||
replace({
|
||||
__IS_DEV__: process.env.NODE_ENV === 'development',
|
||||
preventAssignment: true,
|
||||
values: {
|
||||
__IS_DEV__: process.env.NODE_ENV === 'development',
|
||||
__LIBRECHAT_VERSION__: rootPkg.version,
|
||||
},
|
||||
}),
|
||||
commonjs(),
|
||||
typescript({
|
||||
|
|
|
|||
|
|
@ -960,6 +960,7 @@ export const interfaceSchema = z
|
|||
.optional(),
|
||||
fileSearch: z.boolean().optional(),
|
||||
fileCitations: z.boolean().optional(),
|
||||
buildInfo: z.boolean().optional(),
|
||||
remoteAgents: z
|
||||
.object({
|
||||
use: z.boolean().optional(),
|
||||
|
|
@ -1020,6 +1021,7 @@ export const interfaceSchema = z
|
|||
},
|
||||
fileSearch: true,
|
||||
fileCitations: true,
|
||||
buildInfo: true,
|
||||
remoteAgents: {
|
||||
use: false,
|
||||
create: false,
|
||||
|
|
@ -1134,6 +1136,12 @@ export type TStartupConfig = {
|
|||
>;
|
||||
mcpPlaceholder?: string;
|
||||
conversationImportMaxFileSize?: number;
|
||||
buildInfo?: {
|
||||
commit?: string | null;
|
||||
commitShort?: string | null;
|
||||
branch?: string | null;
|
||||
buildDate?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export enum OCRStrategy {
|
||||
|
|
@ -2106,6 +2114,10 @@ export enum SettingsTabValues {
|
|||
* Tab for Personalization Settings
|
||||
*/
|
||||
PERSONALIZATION = 'personalization',
|
||||
/**
|
||||
* Tab for About / Build Info
|
||||
*/
|
||||
ABOUT = 'about',
|
||||
}
|
||||
|
||||
export enum STTProviders {
|
||||
|
|
@ -2140,8 +2152,16 @@ export enum TTSProviders {
|
|||
|
||||
/** Enum for app-wide constants */
|
||||
export enum Constants {
|
||||
/** Key for the app's version. */
|
||||
VERSION = 'v0.8.6-rc1',
|
||||
/**
|
||||
* Key for the app's version. The placeholder `__LIBRECHAT_VERSION__` is
|
||||
* swapped in by `@rollup/plugin-replace` during `npm run build:data-provider`
|
||||
* using the value of the root `package.json`'s `version` field. Consumers
|
||||
* always import this via the built dist bundle (see `main` field in
|
||||
* `packages/data-provider/package.json`), so production and UI code get the
|
||||
* substituted value. Only tests that import the TypeScript source directly
|
||||
* would observe the raw placeholder.
|
||||
*/
|
||||
VERSION = '__LIBRECHAT_VERSION__',
|
||||
/** Key for the Custom Config's version (librechat.yaml). */
|
||||
CONFIG_VERSION = '1.3.11',
|
||||
/** Standard value for the first message's `parentMessageId` value, to indicate no parent exists. */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue