feat: air-gapped deployment guide, no implicit downloads

- Ship tiktoken's cl100k_base inside the package and build the encoding
  from it, so token counting never downloads anything.
- Default EMBEDDINGS_CACHE_DIR to <data home>/models instead of FastEmbed's
  temp dir, and read tokenizer.json and repo metadata from that cache, so
  a model downloads once and survives reboots.
- TTS_PROVIDER=none and STT_PROVIDER=none switch the speech features off:
  the endpoints return 404, audio files fail to ingest with a clear
  message, /api/config reports tts_available/stt_available, and the UI
  hides the Speak and microphone buttons.
- Drop the Google Fonts Roboto import from the web UI.
- prefetch-models fills the cache the app reads; verify-offline checks the
  packaged encoding.
- Docs: new Air-Gapped Deployment guide, settings and cache notes.
This commit is contained in:
Alex committed 2026-09-15 17:54:24 +01:00
1 parent ca69d1ea29
commit 7da46c2bea
38 files changed
+100892 -95

No files matched your search

+3 -1
View File
@@ -30,6 +30,7 @@ import {
selectSelectedDocs,
selectSelectedModel,
selectSourceDocs,
selectSttAvailable,
selectToken,
setSelectedDocs,
} from '../preferences/preferenceSlice';
@@ -348,6 +349,7 @@ export default function MessageInput({
const token = useSelector(selectToken);
const attachments = useSelector(selectAttachments);
const selectedModel = useSelector(selectSelectedModel);
const sttAvailable = useSelector(selectSttAvailable);
const unreadableAttachmentIds = useMemo(
() =>
new Set(
@@ -1771,7 +1773,7 @@ export default function MessageInput({
loading={toolsLoading}
/>
)}
{ENABLE_VOICE_INPUT && (
{ENABLE_VOICE_INPUT && sttAvailable && (
<MicButton
recordingState={recordingState}
loading={loading}
@@ -27,6 +27,7 @@ import {
selectChunks,
selectSelectedDocs,
selectToken,
selectTtsAvailable,
} from '../preferences/preferenceSlice';
import { isToolCallRunning } from '../utils/streamingStatusUtils';
import AnswerFlow from './AnswerFlow';
@@ -110,6 +111,7 @@ const ConversationBubble = forwardRef<
// const bubbleRef = useRef<HTMLDivElement | null>(null);
const chunks = useSelector(selectChunks);
const selectedDocs = useSelector(selectSelectedDocs);
const ttsAvailable = useSelector(selectTtsAvailable);
const [isEditClicked, setIsEditClicked] = useState(false);
const [editInputBox, setEditInputBox] = useState<string>('');
const messageRef = useRef<HTMLDivElement>(null);
@@ -554,9 +556,11 @@ const ConversationBubble = forwardRef<
</Button>
</div>
)}
<div className="relative block items-center justify-center">
<SpeakButton text={message} />
</div>
{ttsAvailable && (
<div className="relative block items-center justify-center">
<SpeakButton text={message} />
</div>
)}
{handleFeedback && (
<>
<div className="relative flex items-center justify-center">
@@ -81,6 +81,8 @@ const makeStore = (
agentFolders: null,
roles: [],
rolesResolved: false,
ttsAvailable: true,
sttAvailable: true,
};
const conversation: ConversationState = {
queries: [],
+19
View File
@@ -1,6 +1,7 @@
import { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import userService from '../api/services/userService';
import {
getDocs,
getConversations,
@@ -12,6 +13,7 @@ import {
setConversations,
setPrompts,
setSourceDocs,
setSpeechAvailability,
} from '../preferences/preferenceSlice';
/**
@@ -22,6 +24,7 @@ import {
* - Fetching and setting up source documents
* - Fetching and setting up prompts
* - Fetching and setting up conversations
* - Reading which speech features the server has switched on
*
* @param isAuthLoading -
*/
@@ -30,6 +33,22 @@ export default function useDataInitializer(isAuthLoading: boolean) {
const token = useSelector(selectToken);
const conversations = useSelector(selectConversations);
// Speech features; /api/config needs no auth.
useEffect(() => {
userService
.getConfig()
.then((response) => response.json())
.then((config) => {
dispatch(
setSpeechAvailability({
tts: config?.tts_available !== false,
stt: config?.stt_available !== false,
}),
);
})
.catch(() => undefined);
}, [dispatch]);
// Initialize documents
useEffect(() => {
// Skip if auth is still loading
-4
View File
@@ -1,6 +1,3 @@
@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap')
layer(base);
@import 'tailwindcss';
@import 'tw-animate-css';
@@ -8,7 +5,6 @@ layer(base);
@custom-variant dark (&:is(.dark *));
@theme {
--font-roboto: Roboto, sans-serif;
--font-mono:
'IBMPlexMono-Medium', ui-monospace, SFMono-Regular, Menlo, Consolas,
monospace;
+3 -3
View File
@@ -120,7 +120,7 @@ export default function AgentDetailsModal({
</div>
{sharedToken ? (
<div className="flex flex-col gap-2">
<p className="font-roboto dark:text-foreground inline text-sm leading-normal font-medium break-all text-gray-700">
<p className="dark:text-foreground inline text-sm leading-normal font-medium break-all text-gray-700">
<a
href={`${baseURL}/shared/agent/${sharedToken}`}
target="_blank"
@@ -172,7 +172,7 @@ export default function AgentDetailsModal({
{apiKey ? (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<div className="font-roboto dark:text-foreground text-sm leading-normal font-medium break-all text-gray-700">
<div className="dark:text-foreground text-sm leading-normal font-medium break-all text-gray-700">
{apiKey}
{!apiKey.includes('...') && (
<CopyButton
@@ -231,7 +231,7 @@ export default function AgentDetailsModal({
</div>
{webhookUrl ? (
<div className="flex flex-col gap-2">
<p className="font-roboto dark:text-foreground text-sm leading-normal font-medium break-all text-gray-700">
<p className="dark:text-foreground text-sm leading-normal font-medium break-all text-gray-700">
<a href={webhookUrl} target="_blank" rel="noreferrer">
{webhookUrl}
</a>
@@ -44,6 +44,10 @@ export interface Preference {
// not act before the first /me response (avoids a first-paint admin bounce).
roles: string[];
rolesResolved: boolean;
// Speech features the server has switched on (/api/config). True until the
// config loads, so a backend without these flags keeps showing the controls.
ttsAvailable: boolean;
sttAvailable: boolean;
}
const initialState: Preference = {
@@ -74,6 +78,8 @@ const initialState: Preference = {
agentFolders: null,
roles: [],
rolesResolved: false,
ttsAvailable: true,
sttAvailable: true,
};
export const prefSlice = createSlice({
@@ -142,6 +148,13 @@ export const prefSlice = createSlice({
state.roles = [];
state.rolesResolved = false;
},
setSpeechAvailability: (
state,
action: PayloadAction<{ tts: boolean; stt: boolean }>,
) => {
state.ttsAvailable = action.payload.tts;
state.sttAvailable = action.payload.stt;
},
},
});
@@ -166,6 +179,7 @@ export const {
setAgentFolders,
setRoles,
clearRoles,
setSpeechAvailability,
} = prefSlice.actions;
export default prefSlice.reducer;
@@ -314,3 +328,7 @@ export const selectRolesResolved = (state: RootState) =>
state.preference.rolesResolved;
export const selectIsAdmin = (state: RootState) =>
state.preference.roles.includes('admin');
export const selectTtsAvailable = (state: RootState) =>
state.preference.ttsAvailable;
export const selectSttAvailable = (state: RootState) =>
state.preference.sttAvailable;
+2
View File
@@ -56,6 +56,8 @@ const preloadedState: { preference: Preference } = {
agentFolders: null,
roles: [],
rolesResolved: false,
ttsAvailable: true,
sttAvailable: true,
},
};
const store = configureStore({