diff --git a/back/service.mjs b/back/service.mjs
index ed95d018..9b5042f4 100644
--- a/back/service.mjs
+++ b/back/service.mjs
@@ -19,6 +19,7 @@ const configPath = path.resolve(CONFIG_LOCATION);
// Default configuration if config file is missing or invalid
let port = 8081;
let apiEndpoint = "https://chat-ai.academiccloud.de/v1";
+let gatewayEndpoint = apiEndpoint;
let apiKey = "";
let serviceName = "Chat AI Dev";
@@ -34,6 +35,9 @@ try {
);
}
apiEndpoint = config.apiEndpoint;
+ if(config.gatewayEndpoint) {
+ gatewayEndpoint = config.gatewayEndpoint;
+ }
apiKey = config.apiKey;
serviceName = config.serviceName;
} catch (error) {
@@ -219,6 +223,11 @@ app.post("/chat/completions", async (req, res) => {
const mcp_servers = req.body["mcp-servers"] || null;
const inference_id = req.headers["inference-id"];
const uid = req.headers["oidc_claim_uid"];
+ console.log("Incoming /chat/completions request", {
+ method: req.method,
+ path: req.path,
+ headers: req.headers,
+ });
if (!Array.isArray(messages)) {
return res.status(422).json({ error: "Invalid messages provided" });
}
@@ -307,7 +316,7 @@ app.post("/chat/completions", async (req, res) => {
}
}
- const openai = new OpenAI({baseURL : apiEndpoint, apiKey: apiKey ? apiKey : inference_id});
+ const openai = new OpenAI({baseURL : gatewayEndpoint, apiKey: apiKey ? apiKey : inference_id});
// Temporary workaround as middleware doesn't support timeout yet
if (params.arcana || params.model.includes("rag") || params.model.includes("sauerkraut")) delete params.timeout;
@@ -316,6 +325,7 @@ app.post("/chat/completions", async (req, res) => {
const headers = {
"inference-service": inference_service,
"inference-portal": serviceName,
+ "x-user-email": req.headers['oidc_claim_email'],
"user": uid
};
@@ -365,13 +375,14 @@ app.post("/chat/completions", async (req, res) => {
});
}
// Couldn't extract error message, so try without openai library
- const response = await fetch(apiEndpoint + "/chat/completions", {
+ const response = await fetch(gatewayEndpoint + "/chat/completions", {
method: "POST",
headers: {
"Authorization": `Bearer ${ apiKey ? apiKey : inference_id }`,
"Content-Type": "application/json",
"inference-service": inference_service,
"inference-portal": serviceName,
+ "x-user-email": req.headers['oidc_claim_email'],
"inference-id": inference_id
},
body: JSON.stringify(params)
diff --git a/deploy/ansible/deploy.yml b/deploy/ansible/deploy.yml
new file mode 100644
index 00000000..590917f3
--- /dev/null
+++ b/deploy/ansible/deploy.yml
@@ -0,0 +1,233 @@
+---
+- name: Build and deploy frontend and backend
+ hosts: all
+ become: true
+ vars:
+ # Build variables
+ builds_root: /srv/builds
+ build_workspace: "{{ builds_root }}/{{ release_id }}"
+ frontend_source_dir: "{{ build_workspace }}/front"
+ backend_source_dir: "{{ build_workspace }}/back"
+
+ # Symlinks
+ frontend_current_symlink: /srv/frontend-current
+ backend_current_symlink: /srv/backend-current
+
+ # Secrets
+ frontend_secret_path: "/srv/secrets/front.ts"
+ backend_secret_path: "/srv/secrets/back.json"
+
+ # General variables
+ backend_service_name: chat-ai-backend
+ keep_releases: 5
+ prune_start_index: "{{ (keep_releases | int) + 1 }}"
+
+ pre_tasks:
+ - name: Ensure required variables are present
+ ansible.builtin.assert:
+ that:
+ - git_repo_url is defined
+ - git_repo_url | length > 0
+ - git_ref is defined
+ - git_ref | length > 0
+ fail_msg: "Set git_repo_url and git_ref when running the playbook"
+
+ - name: Set default release_id if not provided
+ ansible.builtin.set_fact:
+ release_id: "{{ ansible_date_time.epoch }}"
+ when: release_id is not defined
+
+ - name: Install prerequisite packages on Debian/Ubuntu
+ ansible.builtin.apt:
+ name:
+ - ca-certificates
+ - curl
+ - gnupg
+ - git
+ state: present
+ update_cache: true
+ when: ansible_os_family == "Debian"
+
+ - name: Add NodeSource apt key for Node.js 25
+ ansible.builtin.shell: |
+ set -e
+ install -m 0755 -d /etc/apt/keyrings
+ curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
+ chmod a+r /etc/apt/keyrings/nodesource.gpg
+ args:
+ creates: /etc/apt/keyrings/nodesource.gpg
+ when: ansible_os_family == "Debian"
+
+ - name: Add NodeSource repository for Node.js 25
+ ansible.builtin.apt_repository:
+ repo: "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_25.x nodistro main"
+ filename: nodesource
+ state: present
+ when: ansible_os_family == "Debian"
+
+ - name: Install Node.js 25 on Debian/Ubuntu
+ ansible.builtin.apt:
+ name:
+ - nodejs
+ state: present
+ update_cache: true
+ when: ansible_os_family == "Debian"
+
+ - name: Ensure frontend config exists on server
+ ansible.builtin.stat:
+ path: "{{ frontend_secret_path }}"
+ register: frontend_secret
+
+ - name: Fail if frontend config is missing
+ ansible.builtin.assert:
+ that:
+ - frontend_secret.stat.exists
+ fail_msg: "Expected real frontend config at {{ frontend_secret_path }}"
+
+ - name: Ensure backend config exists on server
+ ansible.builtin.stat:
+ path: "{{ backend_secret_path }}"
+ register: backend_secret
+
+ - name: Fail if backend config is missing
+ ansible.builtin.assert:
+ that:
+ - backend_secret.stat.exists
+ fail_msg: "Expected real backend config at {{ backend_secret_path }}"
+
+ tasks:
+ - name: Ensure builds directory exists
+ ansible.builtin.file:
+ path: "{{ builds_root }}"
+ state: directory
+ mode: "0755"
+
+ - name: Checkout repository for this release
+ ansible.builtin.git:
+ repo: "{{ git_repo_url }}"
+ dest: "{{ build_workspace }}"
+ version: "{{ git_ref }}"
+ force: true
+
+ - name: Ensure secrets directory exists in workspace
+ ansible.builtin.file:
+ path: "{{ build_workspace }}/secrets"
+ state: directory
+ mode: "0755"
+
+ - name: Copy real frontend config into build workspace
+ ansible.builtin.copy:
+ src: "{{ frontend_secret_path }}"
+ dest: "{{ build_workspace }}/secrets/front.ts"
+ remote_src: true
+ mode: "0600"
+
+ - name: Copy real backend config into build workspace
+ ansible.builtin.copy:
+ src: "{{ backend_secret_path }}"
+ dest: "{{ build_workspace }}/secrets/back.json"
+ remote_src: true
+ mode: "0600"
+
+ # Frontend build
+ - name: Install frontend dependencies
+ ansible.builtin.command: npm install
+ args:
+ chdir: "{{ frontend_source_dir }}"
+
+ - name: Build frontend with real config
+ ansible.builtin.command: npm run build
+ args:
+ chdir: "{{ frontend_source_dir }}"
+
+ # Backend build (npm install for dependencies)
+ - name: Install backend dependencies
+ ansible.builtin.command: npm install
+ args:
+ chdir: "{{ backend_source_dir }}"
+
+ # Prepare backend release directory
+ - name: Ensure backend build directory exists
+ ansible.builtin.file:
+ path: "{{ backend_source_dir }}/dist"
+ state: directory
+ mode: "0755"
+
+ - name: Create backend distribution with necessary files
+ ansible.builtin.shell: |
+ cp "{{ backend_source_dir }}/service.mjs" "{{ backend_source_dir }}/dist/"
+ cp "{{ backend_source_dir }}/package.json" "{{ backend_source_dir }}/dist/"
+ cp "{{ backend_source_dir }}/package-lock.json" "{{ backend_source_dir }}/dist/" 2>/dev/null || true
+ cp -r "{{ backend_source_dir }}/node_modules" "{{ backend_source_dir }}/dist/" || true
+
+ - name: Create backend config symlink in dist directory
+ ansible.builtin.file:
+ src: "{{ backend_secret_path }}"
+ dest: "{{ backend_source_dir }}/dist/back.json"
+ state: link
+ force: true
+
+ # Atomic symlink switches
+ - name: Atomically switch frontend current symlink
+ ansible.builtin.shell: |
+ ln -sfn "{{ frontend_source_dir }}/dist" "{{ builds_root }}/.frontend_tmp"
+ mv -Tf "{{ builds_root }}/.frontend_tmp" "{{ frontend_current_symlink }}"
+
+ - name: Atomically switch backend current symlink
+ ansible.builtin.shell: |
+ ln -sfn "{{ backend_source_dir }}/dist" "{{ builds_root }}/.backend_tmp"
+ mv -Tf "{{ builds_root }}/.backend_tmp" "{{ backend_current_symlink }}"
+
+ # Backend service management
+ - name: Create backend systemd service file
+ ansible.builtin.copy:
+ content: |
+ [Unit]
+ Description=Chat AI Backend Service
+ After=network.target
+
+ [Service]
+ Type=simple
+ User=www-data
+ WorkingDirectory={{ backend_current_symlink }}
+ ExecStart=/usr/bin/node {{ backend_current_symlink }}/service.mjs
+ Restart=always
+ RestartSec=10
+ StandardOutput=journal
+ StandardError=journal
+ SyslogIdentifier=chat-ai-backend
+ Environment="NODE_ENV=production"
+ Environment="CONFIG_LOCATION={{ backend_secret_path }}"
+
+ [Install]
+ WantedBy=multi-user.target
+ dest: "/etc/systemd/system/{{ backend_service_name }}.service"
+ mode: "0644"
+ notify: Restart backend service
+
+ - name: Enable backend service
+ ansible.builtin.systemd:
+ name: "{{ backend_service_name }}"
+ enabled: true
+ daemon_reload: true
+
+ - name: Gather service facts
+ ansible.builtin.service_facts:
+
+ - name: Restart backend service if it exists
+ ansible.builtin.systemd:
+ name: "{{ backend_service_name }}"
+ state: restarted
+ when: (backend_service_name ~ '.service') in ansible_facts.services
+
+ # Cleanup old builds
+ - name: Prune old builds and keep latest
+ ansible.builtin.shell: |
+ ls -1dt {{ builds_root }}/* 2>/dev/null | tail -n +{{ prune_start_index }} | xargs -r rm -rf
+
+ handlers:
+ - name: Restart backend service
+ ansible.builtin.systemd:
+ name: "{{ backend_service_name }}"
+ state: restarted
+ daemon_reload: true
diff --git a/deploy/ansible/deploy_frontend.yml b/deploy/ansible/deploy_frontend.yml
new file mode 100644
index 00000000..b1709ed8
--- /dev/null
+++ b/deploy/ansible/deploy_frontend.yml
@@ -0,0 +1,145 @@
+---
+- name: Build and deploy frontend
+ hosts: frontend_servers
+ become: true
+ vars:
+ deploy_root: /srv/frontend
+ releases_root: "{{ deploy_root }}/releases"
+ builds_root: "{{ deploy_root }}/builds"
+ current_symlink: "{{ deploy_root }}/current"
+ keep_releases: 5
+ build_workspace: "{{ builds_root }}/{{ release_id }}"
+ frontend_source_dir: "{{ build_workspace }}/front"
+ frontend_secret_path: "/srv/secrets/front.ts"
+ release_path: "{{ releases_root }}/{{ release_id }}"
+ prune_start_index: "{{ (keep_releases | int) + 1 }}"
+
+ pre_tasks:
+ - name: Ensure required variables are present
+ ansible.builtin.assert:
+ that:
+ - git_repo_url is defined
+ - git_repo_url | length > 0
+ - git_ref is defined
+ - git_ref | length > 0
+ fail_msg: "Set git_repo_url and git_ref when running the playbook"
+
+ - name: Set default release_id if not provided
+ ansible.builtin.set_fact:
+ release_id: "{{ ansible_date_time.epoch }}"
+ when: release_id is not defined
+
+ - name: Install prerequisite packages on Debian/Ubuntu
+ ansible.builtin.apt:
+ name:
+ - ca-certificates
+ - curl
+ - gnupg
+ - git
+ state: present
+ update_cache: true
+ when: ansible_os_family == "Debian"
+
+ - name: Add NodeSource apt key for Node.js 25
+ ansible.builtin.shell: |
+ set -e
+ install -m 0755 -d /etc/apt/keyrings
+ curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
+ chmod a+r /etc/apt/keyrings/nodesource.gpg
+ args:
+ creates: /etc/apt/keyrings/nodesource.gpg
+ when: ansible_os_family == "Debian"
+
+ - name: Add NodeSource repository for Node.js 25
+ ansible.builtin.apt_repository:
+ repo: "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_25.x nodistro main"
+ filename: nodesource
+ state: present
+ when: ansible_os_family == "Debian"
+
+ - name: Install Node.js 25 on Debian/Ubuntu
+ ansible.builtin.apt:
+ name:
+ - nodejs
+ state: present
+ update_cache: true
+ when: ansible_os_family == "Debian"
+
+ - name: Ensure frontend config exists on server
+ ansible.builtin.stat:
+ path: "{{ frontend_secret_path }}"
+ register: frontend_secret
+
+ - name: Fail if frontend config is missing
+ ansible.builtin.assert:
+ that:
+ - frontend_secret.stat.exists
+ fail_msg: "Expected real frontend config at {{ frontend_secret_path }}"
+
+ tasks:
+ - name: Ensure deploy directories exist
+ ansible.builtin.file:
+ path: "{{ item }}"
+ state: directory
+ mode: "0755"
+ loop:
+ - "{{ deploy_root }}"
+ - "{{ releases_root }}"
+ - "{{ builds_root }}"
+
+ - name: Checkout repository for this release
+ ansible.builtin.git:
+ repo: "{{ git_repo_url }}"
+ dest: "{{ build_workspace }}"
+ version: "{{ git_ref }}"
+ force: true
+
+ - name: Ensure secrets directory exists in workspace
+ ansible.builtin.file:
+ path: "{{ build_workspace }}/secrets"
+ state: directory
+ mode: "0755"
+
+ - name: Copy real frontend config into build workspace
+ ansible.builtin.copy:
+ src: "{{ frontend_secret_path }}"
+ dest: "{{ build_workspace }}/secrets/front.json"
+ remote_src: true
+ mode: "0600"
+
+ - name: Install frontend dependencies
+ ansible.builtin.command: npm install
+ args:
+ chdir: "{{ frontend_source_dir }}"
+
+ - name: Build frontend with real config
+ ansible.builtin.command: npm run build
+ args:
+ chdir: "{{ frontend_source_dir }}"
+
+ - name: Create release directory
+ ansible.builtin.file:
+ path: "{{ release_path }}"
+ state: directory
+ mode: "0755"
+
+ - name: Copy built assets to release directory
+ ansible.builtin.command: cp -a "{{ frontend_source_dir }}/dist/." "{{ release_path }}/"
+
+ - name: Atomically switch current release symlink
+ ansible.builtin.shell: |
+ ln -sfn "{{ release_path }}" "{{ deploy_root }}/.current_tmp"
+ mv -Tf "{{ deploy_root }}/.current_tmp" "{{ current_symlink }}"
+
+ - name: Remove temporary build workspace
+ ansible.builtin.file:
+ path: "{{ build_workspace }}"
+ state: absent
+
+ - name: Prune old releases and keep latest
+ ansible.builtin.shell: |
+ ls -1dt {{ releases_root }}/* 2>/dev/null | tail -n +{{ prune_start_index }} | xargs -r rm -rf
+
+ - name: Prune old builds and keep latest
+ ansible.builtin.shell: |
+ ls -1dt {{ builds_root }}/* 2>/dev/null | tail -n +{{ prune_start_index }} | xargs -r rm -rf
\ No newline at end of file
diff --git a/deploy/ansible/inventory/hosts.ini b/deploy/ansible/inventory/hosts.ini
new file mode 100644
index 00000000..36e1c5cd
--- /dev/null
+++ b/deploy/ansible/inventory/hosts.ini
@@ -0,0 +1,3 @@
+[frontend_servers]
+# c104-051.cloud.gwdg.de (reachable as ssh alias: ohb-chatbot)
+ohb-chatbot ansible_user=cloud ansible_python_interpreter=/usr/bin/python3
diff --git a/front/Dockerfile b/front/Dockerfile
index 276a89e4..1f4d25ca 100644
--- a/front/Dockerfile
+++ b/front/Dockerfile
@@ -1,5 +1,5 @@
# We are using the node base image
-FROM node:latest
+FROM node:25
# Configure config location
ENV CONFIG_LOCATION=/run/secrets/front
diff --git a/front/package.json b/front/package.json
index c92f02e2..0144e989 100644
--- a/front/package.json
+++ b/front/package.json
@@ -26,6 +26,7 @@
"@vitejs/plugin-react": "^5.0.0",
"dexie": "^4.0.11",
"dexie-react-hooks": "^1.1.7",
+ "docx": "^9.7.1",
"dompurify": "^3.2.6",
"formik": "^2.4.6",
"framer-motion": "^12.23.12",
diff --git a/front/public/OHB-Chatbot__DE___EN__v1.0.pdf b/front/public/OHB-Chatbot__DE___EN__v1.0.pdf
new file mode 100644
index 00000000..cee00c48
Binary files /dev/null and b/front/public/OHB-Chatbot__DE___EN__v1.0.pdf differ
diff --git a/front/src/Pages/ChatPage.tsx b/front/src/Pages/ChatPage.tsx
index 09f58c1c..4bf2d7cd 100644
--- a/front/src/Pages/ChatPage.tsx
+++ b/front/src/Pages/ChatPage.tsx
@@ -20,6 +20,9 @@ import { setLastConversation } from "../Redux/reducers/lastConversationSlice";
import { Navigate, useNavigate } from "react-router";
import AnnouncementBar from "../components/Header/AnnouncementBar";
+import config from "../config";
+import MPGHeader from "../components/Header/MPGHeader";
+
export default function ChatPage() {
const params = useParams();
@@ -27,6 +30,8 @@ export default function ChatPage() {
const dispatch = useDispatch();
const navigate = useNavigate();
const { isMobile } = useWindowSize();
+ const hideFooter = config.overrides?.ui?.hideFooter;
+ const hideSettings = config.overrides?.ui?.hideSettings;
const [localState, setLocalState] = useState(() => getDefaultConversation());
@@ -34,6 +39,7 @@ export default function ChatPage() {
const userData = useUpdateUserData();
// Sync localState conversation with IndexedDB
+ // @ts-ignore
useSyncConversation({
localState,
setLocalState,
@@ -51,6 +57,9 @@ export default function ChatPage() {
{/* Header + optional Announcement */}
+ { config.overrides?.branding === "mpg" &&
+
+ }
-
+ {!hideSettings && (
+
+ )}
-
+ {!hideFooter && (
+
+ )}
);
diff --git a/front/src/Redux/reducers/userSettingsReducer.jsx b/front/src/Redux/reducers/userSettingsReducer.jsx
index e5bacd67..df235432 100644
--- a/front/src/Redux/reducers/userSettingsReducer.jsx
+++ b/front/src/Redux/reducers/userSettingsReducer.jsx
@@ -1,9 +1,10 @@
// userMemorySlice.js
import { createSlice } from "@reduxjs/toolkit";
+import config from "../../config";
const initialState = {
memories: [],
- // model: "", // TODO load from file
+ model: config.default.model,
timeout: 300000,
};
diff --git a/front/src/Redux/store/store.jsx b/front/src/Redux/store/store.jsx
index e479f901..ee0de4d9 100644
--- a/front/src/Redux/store/store.jsx
+++ b/front/src/Redux/store/store.jsx
@@ -8,6 +8,7 @@ import {
initMessageListener,
} from "redux-state-sync";
import { migrations } from "./migrations";
+import config from "../../config";
const persistConfig = {
key: "root",
@@ -29,7 +30,7 @@ const getDefaultState = () => {
dark_mode: false,
show_settings: false,
show_sidebar: true,
- show_tour: true,
+ show_tour: config.overrides?.ui?.show_tour ?? true,
warn_clear_history: true,
warn_clear_memory: true,
warn_clear_settings: true,
@@ -40,6 +41,7 @@ const getDefaultState = () => {
user_settings: {
memories: [],
timeout: 300000,
+ model: config.default.model,
},
};
};
diff --git a/front/src/apis/chatCompletions.jsx b/front/src/apis/chatCompletions.jsx
index 6acde502..6591f672 100644
--- a/front/src/apis/chatCompletions.jsx
+++ b/front/src/apis/chatCompletions.jsx
@@ -1,4 +1,5 @@
import OpenAI from "openai";
+import config from "../config";
// Controller for handling API request cancellation
let controller = new AbortController();
@@ -14,7 +15,7 @@ async function* chatCompletions (
: conversation.settings.model?.id; // TODO fall back to defaultModel
// Define base URL from config
- let baseURL = import.meta.env.VITE_BACKEND_ENDPOINT;
+ let baseURL = config.backendPath ?? "";
try {
// If absolute, parse directly
baseURL = new URL(baseURL).toString();
@@ -68,6 +69,11 @@ async function* chatCompletions (
if (!stream) {
const result = streamResponse;
+ // Structured RAG references are now returned as a top-level `references`
+ // field on the completion object (the OpenAI SDK keeps unknown fields).
+ if (result?.references) {
+ console.log("Reference JSON (non-streaming) extracted from result.references:", result.references);
+ }
console.log("Error:", result);
return result;
}
@@ -84,6 +90,11 @@ async function* chatCompletions (
err.code = chunk?.code || chunk?.status;
throw err;
}
+ // Structured RAG references arrive as a top-level `references` field on the
+ // final (stop) chunk — the OpenAI SDK preserves it as chunk.references.
+ if (chunk?.references) {
+ console.log("Reference JSON (streaming) extracted from chunk.references:", chunk.references);
+ }
try {
if (!completed) {
answer += chunk.choices[0].delta?.content || ""
diff --git a/front/src/apis/checkService.jsx b/front/src/apis/checkService.jsx
index b09c345c..554382f4 100644
--- a/front/src/apis/checkService.jsx
+++ b/front/src/apis/checkService.jsx
@@ -1,12 +1,13 @@
import { getDefaultSettings } from "../utils/conversationUtils";
import OpenAI from "openai";
+import config from "../config";
// Tests if a specific model is available and responsive
export async function checkService(model) {
const defaultSettings = getDefaultSettings();
try {
- let baseURL = import.meta.env.VITE_BACKEND_ENDPOINT;
+ let baseURL = config.backendPath ?? "";
try {
// If absolute, parse directly
baseURL = new URL(baseURL).toString();
diff --git a/front/src/apis/generateChoiceProposal.jsx b/front/src/apis/generateChoiceProposal.jsx
index 2bc26d7d..695d7664 100644
--- a/front/src/apis/generateChoiceProposal.jsx
+++ b/front/src/apis/generateChoiceProposal.jsx
@@ -1,5 +1,6 @@
import { getDefaultSettings } from "../utils/conversationUtils";
import OpenAI from "openai";
+import config from "../config";
export default async function generateChoiceProposal(history) {
const defaultSettings = getDefaultSettings();
@@ -33,7 +34,7 @@ export default async function generateChoiceProposal(history) {
try {
// Define base URL from config
- let baseURL = import.meta.env.VITE_BACKEND_ENDPOINT;
+ let baseURL = config.backendPath ?? "";
try {
// If absolute, parse directly
baseURL = new URL(baseURL).toString();
@@ -52,7 +53,7 @@ export default async function generateChoiceProposal(history) {
const params = {
- model: import.meta.env.VITE_PROPOSAL_GENERATION_MODEL || defaultSettings.model.id,
+ model: config.proposalGenerationModel || defaultSettings.model.id,
messages: [
{
role: "system",
diff --git a/front/src/apis/generateMemory.jsx b/front/src/apis/generateMemory.jsx
index a6a99d51..18d5ad13 100644
--- a/front/src/apis/generateMemory.jsx
+++ b/front/src/apis/generateMemory.jsx
@@ -1,5 +1,6 @@
import { getDefaultSettings } from "../utils/conversationUtils";
import OpenAI from "openai";
+import config from "../config";
export default async function generateMemory(newUserMessage, memories) {
const defaultSettings = getDefaultSettings();
@@ -77,7 +78,7 @@ export default async function generateMemory(newUserMessage, memories) {
try {
// Define base URL from config
- let baseURL = import.meta.env.VITE_BACKEND_ENDPOINT;
+ let baseURL = BACKEND_ENDPOINT;
try {
// If absolute, parse directly
baseURL = new URL(baseURL).toString();
@@ -96,7 +97,7 @@ export default async function generateMemory(newUserMessage, memories) {
const params = {
- model: import.meta.env.VITE_MEMORY_GENERATION_MODEL || defaultSettings.model.id,
+ model: MEMORY_GENERATION_MODEL || defaultSettings.model.id,
messages: [
{
role: "system",
diff --git a/front/src/apis/generateTitle.jsx b/front/src/apis/generateTitle.jsx
index 9228a31e..49e39435 100644
--- a/front/src/apis/generateTitle.jsx
+++ b/front/src/apis/generateTitle.jsx
@@ -1,5 +1,6 @@
import { getDefaultSettings } from "../utils/conversationUtils";
import OpenAI from "openai";
+import config from "../config";
export default async function generateTitle(messages) {
const defaultSettings = getDefaultSettings();
@@ -52,7 +53,7 @@ export default async function generateTitle(messages) {
try {
// Define base URL from config
- let baseURL = import.meta.env.VITE_BACKEND_ENDPOINT;
+ let baseURL = config.backendPath ?? "";
try {
// If absolute, parse directly
baseURL = new URL(baseURL).toString();
@@ -71,7 +72,7 @@ export default async function generateTitle(messages) {
// Initialize params
const params = {
- model: import.meta.env.VITE_TITLE_GENERATION_MODEL || defaultSettings.model.id,
+ model: config.titleGenerationModel || defaultSettings.model.id,
messages: [
{ role: "system", content: "You are a helpful assistant." },
...processedMessages,
diff --git a/front/src/apis/getModelsData.jsx b/front/src/apis/getModelsData.jsx
index 5a695892..a5f3c03b 100644
--- a/front/src/apis/getModelsData.jsx
+++ b/front/src/apis/getModelsData.jsx
@@ -1,7 +1,9 @@
+import config from "../config";
+
// Gets available models data from the server
export async function getModelsData() {
try {
- const response = await fetch(import.meta.env.VITE_MODELS_ENDPOINT);
+ const response = await fetch(config.modelsPath ?? "");
// If failed, return response with error
if (!response.ok) {
return response;
@@ -9,10 +11,17 @@ export async function getModelsData() {
// Extract model data from response
const { data: modelsData } = await response.json();
// Enrich model data with names if not present
- const enrichedModelsData = modelsData.map((model) => ({
+ let enrichedModelsData = modelsData.map((model) => ({
...model,
name: model.name || model.id,
}));
+ console.info(`Apply model filters: whitelist=${config.overrides?.models?.whitelist}, blacklist=${config.overrides?.models?.blacklist} on models:`, enrichedModelsData);
+ if (config.overrides?.models?.whitelist) {
+ enrichedModelsData = enrichedModelsData.filter(model => config.overrides.models.whitelist.includes(model.id));
+ }
+ if (config.overrides?.models?.blacklist) {
+ enrichedModelsData = enrichedModelsData.filter(model => !config.overrides.models.blacklist.includes(model.id));
+ }
return enrichedModelsData;
} catch (error) {
console.error("Failed to load models data", error);
diff --git a/front/src/apis/getUserData.jsx b/front/src/apis/getUserData.jsx
index fb55fde8..8edc5a34 100644
--- a/front/src/apis/getUserData.jsx
+++ b/front/src/apis/getUserData.jsx
@@ -1,7 +1,9 @@
+import config from "../config";
+
// Fetches authenticated user's profile data from the server
export const getUserData = async () => {
try {
- const response = await fetch(import.meta.env.VITE_USERDATA_ENDPOINT);
+ const response = await fetch(config.userDataPath ?? "");
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
diff --git a/front/src/apis/processFile.jsx b/front/src/apis/processFile.jsx
index 6884b205..aad24a35 100644
--- a/front/src/apis/processFile.jsx
+++ b/front/src/apis/processFile.jsx
@@ -1,3 +1,5 @@
+import config from "../config";
+
// PDF processing function
export const processFile = async (file) => {
try {
@@ -5,7 +7,7 @@ export const processFile = async (file) => {
formData.append("document", file);
const response = await fetch(
- import.meta.env.VITE_BACKEND_ENDPOINT + "/documents",
+ (config.backendPath ?? "") + "/documents",
{
method: "POST",
body: formData,
diff --git a/front/src/assets/icons/file_docx.svg b/front/src/assets/icons/file_docx.svg
new file mode 100644
index 00000000..d29f1091
--- /dev/null
+++ b/front/src/assets/icons/file_docx.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/front/src/components/Conversation/Conversation.jsx b/front/src/components/Conversation/Conversation.jsx
index 958f42e6..3fbe1fc3 100644
--- a/front/src/components/Conversation/Conversation.jsx
+++ b/front/src/components/Conversation/Conversation.jsx
@@ -12,9 +12,12 @@ import HallucinationWarning from "./HallucinationWarning";
import MessageAssistant from "./MessageAssistant/MessageAssistant";
import MessageUser from "./MessageUser/MessageUser";
import Motto from "./Motto";
+import OhbDisclaimer from "./OhbDisclaimer";
import UndoButton from "./UndoButton";
import SummaryButton from "./SummaryButton";
+import config from "../../config";
+
export default function Conversation({
localState,
setLocalState,
@@ -38,6 +41,7 @@ export default function Conversation({
const resizeObserver = useRef(null);
const programmaticGuardUntil = useRef(0); // ignore our own scrolls for a short window
const smoothTimer = useRef(null);
+ const prevMsgLengthRef = useRef(0);
// Helpers
const hasOverflow = useCallback(() => {
@@ -189,6 +193,24 @@ export default function Conversation({
return () => clearTimeout(t);
}, [copied]);
+ // Re-enable follow and jump to bottom whenever the user sends a message
+ useEffect(() => {
+ const msgs = localState?.messages;
+ if (!msgs) return;
+
+ const prev = prevMsgLengthRef.current;
+ prevMsgLengthRef.current = msgs.length;
+
+ // sendMessage adds 2 messages at once (assistant loading + empty user placeholder)
+ if (msgs.length === prev + 2) {
+ const secondToLast = msgs[msgs.length - 2];
+ if (secondToLast?.role === "assistant" && secondToLast?.loading === true) {
+ setAutoFollow(true);
+ requestAnimationFrame(() => scrollToBottom("auto"));
+ }
+ }
+ }, [localState?.messages, scrollToBottom]);
+
// Lifecycle nudges: first message / sending while at bottom
useEffect(() => {
const msgs = localState?.messages;
@@ -223,13 +245,15 @@ export default function Conversation({
${emptyConversation ? "justify-start" : "justify-between"}`}
>
{/* Model selector at top, on tablet and desktop */}
-
-
-
+ {config?.overrides?.ui?.hideModelSelector !== true && (
+
+
+
+ )}
{/* Empty conversation */}
{emptyConversation && (
@@ -255,10 +278,9 @@ export default function Conversation({
className={`flex flex-col relative w-full rounded-xl
bg-white dark:bg-bg_secondary_dark shadow-md dark:shadow-dark
transition-opacity duration-500 ease-in-out
- ${
- localState.messages.length <= 2
- ? "max-h-0 opacity-0 scale-0 pointer-events-none overflow-hidden"
- : "scale-100 opacity-100 flex-1 min-h-0"
+ ${localState.messages.length <= 2
+ ? "max-h-0 opacity-0 scale-0 pointer-events-none overflow-hidden"
+ : "scale-100 opacity-100 flex-1 min-h-0"
}`}
>
{/* Hallucination Warning */}
@@ -302,6 +324,7 @@ export default function Conversation({
)}
))}
+
{/* Floating scroll-to-bottom button (only when overflow && NOT at bottom) */}
@@ -345,9 +368,9 @@ export default function Conversation({
/>
{/* Summary button */}
+ localState={localState}
+ setLocalState={setLocalState}
+ />
{/* Export button */}
@@ -366,7 +389,8 @@ export default function Conversation({
{/* Prompt */}
-
+
+ {emptyConversation && }
{emptyConversation && }
);
diff --git a/front/src/components/Conversation/HallucinationWarning.jsx b/front/src/components/Conversation/HallucinationWarning.jsx
index 6737442d..24d24903 100644
--- a/front/src/components/Conversation/HallucinationWarning.jsx
+++ b/front/src/components/Conversation/HallucinationWarning.jsx
@@ -1,48 +1,17 @@
-import { Link } from "react-router-dom";
import { Trans } from "react-i18next";
-import { useSelector, useDispatch } from "react-redux";
-import { useState } from "react";
-//Assets
-import { X } from "lucide-react";
-
-import {
- selectCountHallucination,
- selectShowSettings,
-} from "../../Redux/reducers/interfaceSettingsSlice";
-import { closeHallucination } from "../../Redux/reducers/interfaceSettingsSlice";
+import { TriangleAlert } from "lucide-react";
+// Persistent short notice (banner) shown in active conversations.
+// Replaces the former dismissible hallucination warning.
export default function HallucinationWarning() {
- const dispatch = useDispatch();
- const countHallucination = useSelector(selectCountHallucination);
- const [closedHallucination, setClosedHallucination] = useState(false);
-
- const handleClose = () => {
- setClosedHallucination(true); // Hide immediately
- dispatch(closeHallucination()); // Increment counter
- };
-
- return countHallucination < 3 && !closedHallucination ? (
+ return (
- ) : null;
+ );
}
diff --git a/front/src/components/Conversation/MessageAssistant/MarkdownRenderer.jsx b/front/src/components/Conversation/MessageAssistant/MarkdownRenderer.jsx
index bd8a49da..dc871a8b 100644
--- a/front/src/components/Conversation/MessageAssistant/MarkdownRenderer.jsx
+++ b/front/src/components/Conversation/MessageAssistant/MarkdownRenderer.jsx
@@ -496,7 +496,7 @@ export const SafeMarkdown = ({
* Main MarkdownRenderer
* -------------------------------------------- */
const MarkdownRenderer = memo(
- ({ children, isDarkMode, isLoading, renderMode = "Default" }) => {
+ ({ children, isDarkMode, isLoading, renderMode = "Default", references }) => {
// KaTeX styling
useEffect(() => {
const style = document.createElement("style");
@@ -706,13 +706,13 @@ const MarkdownRenderer = memo(
{renderContentByMode()}
- {finalReferences && (
+ {Array.isArray(references) && references.length > 0 ? (
- )}
+ ) : null}
>
);
}
diff --git a/front/src/components/Conversation/MessageAssistant/MessageAssistant.jsx b/front/src/components/Conversation/MessageAssistant/MessageAssistant.jsx
index f7bf9c40..af846c1a 100644
--- a/front/src/components/Conversation/MessageAssistant/MessageAssistant.jsx
+++ b/front/src/components/Conversation/MessageAssistant/MessageAssistant.jsx
@@ -4,12 +4,13 @@ import Typing from "./Typing";
import CopyButton from "./CopyButton";
import Attachment from "../../Prompt/Attachment";
import EditButton from "./EditButton";
-import { RotateCw, GitFork } from "lucide-react";
+import { RotateCw, GitFork, Loader2 } from "lucide-react";
import { useSendMessage } from "../../../hooks/useSendMessage";
import { useForkConversation } from "../../../hooks/useForkConversation";
import MetaBox from "./MetaBox";
import FeedbackButtons from "./FeedbackButtons";
import ForkButton from "./ForkButton";
+import config from "../../../config";
import SpeakButton from "./SpeakButton";
// Constants
@@ -30,7 +31,7 @@ export default React.memo(({ localState, setLocalState, message_index }) => {
const sendMessage = useSendMessage();
const { forkConversation } = useForkConversation(localState);
- const feedbackModule = import.meta.env.VITE_MODULE_FEEDBACK === "true";
+ const feedbackModule = config.modules?.feedback;
//Functions
const adjustHeight = () => {
@@ -287,7 +288,11 @@ export default React.memo(({ localState, setLocalState, message_index }) => {
{/* Display message content */}
{!editMode && !feedbackMode && (
-
+
{message.content[0]?.text}
{/* Attachments Section */}
@@ -308,27 +313,31 @@ export default React.memo(({ localState, setLocalState, message_index }) => {
)}
{/* Bottom panel for message */}
-
- {/* Render Mode Selector on the bottom left*/}
-
- {renderModes.map((mode) => (
- !loading && setRenderMode(mode)}
- className={`px-2 py-1 text-xs font-medium transition-all duration-300 ease-in-out min-w-[60px] cursor-pointer select-none
- ${loading ? "cursor-not-allowed opacity-20" : ""}
- ${
- renderMode === mode
- ? "bg-tertiary text-white"
- : "text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600"
- }
- `}
- disabled={loading}
- >
- {mode}
-
- ))}
-
+
+ {loading ? (
+
+ ) : (
+
+ {/* Render Mode Selector on the bottom left*/}
+
+ {renderModes.map((mode) => (
+ setRenderMode(mode)}
+ className={`px-2 py-1 text-xs font-medium transition-all duration-300 ease-in-out min-w-[60px] cursor-pointer select-none
+ ${
+ renderMode === mode
+ ? "bg-tertiary text-white"
+ : "text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600"
+ }
+ `}
+ >
+ {mode}
+
+ ))}
+
+
+ )}
{feedbackModule && (
diff --git a/front/src/components/Conversation/MessageAssistant/ReferencesSection.jsx b/front/src/components/Conversation/MessageAssistant/ReferencesSection.jsx
index 8c034cd8..1b7905a6 100644
--- a/front/src/components/Conversation/MessageAssistant/ReferencesSection.jsx
+++ b/front/src/components/Conversation/MessageAssistant/ReferencesSection.jsx
@@ -33,11 +33,25 @@ const ProgressiveReferenceItem = memo(function ProgressiveReferenceItem({
}, [isVisible, isRendered, index, onRenderComplete]);
const { titleText, contentBody, hasContent, isPartial } = useMemo(() => {
+ const title =
+ reference?.title || `Reference ${reference?.rrefNumber ?? index + 1}`;
+
+ // Structured references already carry the full document text as `body`.
+ if (reference?.body != null) {
+ const body = String(reference.body).trim();
+ return {
+ titleText: title,
+ contentBody: body,
+ hasContent: body.length > 0,
+ isPartial: false,
+ };
+ }
+
const raw = reference?.content || "";
const lines = raw.split("\n").filter((l) => l.trim().length > 0);
const firstLine = lines[0] || "";
- const title =
+ const legacyTitle =
reference?.title ||
firstLine.replace(/\s*\[RREF\d+\]\s*/i, "").trim() ||
`Reference ${reference?.rrefNumber ?? index + 1}`;
@@ -47,7 +61,7 @@ const ProgressiveReferenceItem = memo(function ProgressiveReferenceItem({
isStreaming && !isComplete && (raw.length < 50 || !raw.includes("\n"));
return {
- titleText: title,
+ titleText: legacyTitle,
contentBody: body,
hasContent: body.length > 0 || partialFlag,
isPartial: partialFlag,
@@ -93,31 +107,49 @@ const ProgressiveReferenceItem = memo(function ProgressiveReferenceItem({
{reference?.rrefNumber || index + 1}
-
-
- {titleText}
-
+
+ {/* Row 1: source file name (+ arcana / link) */}
+
+
+ {/* Row 2: Fachbereich · section title, muted */}
+ {(reference?.fachbereich || reference?.sectionTitle) && (
+
+ {[reference?.fachbereich, reference?.sectionTitle]
+ .filter(Boolean)
+ .join(" · ")}
+
)}
@@ -168,8 +200,37 @@ const ProgressiveReferenceItem = memo(function ProgressiveReferenceItem({
);
});
+// Map the structured RAG reference objects returned by the backend onto the
+// shape the reference items expect, exposing the richer metadata fields.
+const normalizeStructuredReferences = (refs) =>
+ (Array.isArray(refs) ? refs : []).map((r, i) => {
+ const frontmatter = r?.frontmatter || {};
+ const fachbereich =
+ r?.frontmatter_meta?.fachbereich || frontmatter?.fachbereich || "";
+ const sectionPath = Array.isArray(r?.section_path)
+ ? r.section_path.filter(Boolean)
+ : r?.section_path
+ ? [r.section_path]
+ : [];
+ // Only show the most specific (last) section element.
+ const sectionTitle = sectionPath.length
+ ? sectionPath[sectionPath.length - 1]
+ : "";
+ return {
+ number: i,
+ rrefNumber: i + 1,
+ title: frontmatter?.title || r?.filename || `Reference ${i + 1}`,
+ url: r?.url || frontmatter?.url || null,
+ sectionTitle,
+ fachbereich,
+ arcanaName: r?.arcana_name || "",
+ body: r?.text || "",
+ };
+ });
+
const ReferencesSection = memo(function ReferencesSection({
content,
+ structuredReferences,
isLoading,
isStreaming = false,
}) {
@@ -179,13 +240,17 @@ const ReferencesSection = memo(function ReferencesSection({
const references = useMemo(() => {
try {
+ // Prefer the structured references; fall back to the legacy string parse.
+ if (Array.isArray(structuredReferences) && structuredReferences.length > 0) {
+ return normalizeStructuredReferences(structuredReferences);
+ }
if (!content) return [];
return parseReferences(content); // returns RAW markdown blocks
} catch (e) {
console.error("Error parsing references:", e);
return [];
}
- }, [content]);
+ }, [content, structuredReferences]);
useEffect(() => {
if (references.length > 0) {
@@ -217,11 +282,10 @@ const ReferencesSection = memo(function ReferencesSection({
references
.map((ref) => {
const t = ref.title || `Reference ${ref.rrefNumber}`;
- const body = (ref.content || "")
- .split("\n")
- .slice(1)
- .join("\n")
- .trim();
+ const body =
+ ref.body != null
+ ? String(ref.body).trim()
+ : (ref.content || "").split("\n").slice(1).join("\n").trim();
return body ? `${t}\n\n${body}` : t;
})
.join("\n\n"),
diff --git a/front/src/components/Conversation/OhbDisclaimer.jsx b/front/src/components/Conversation/OhbDisclaimer.jsx
new file mode 100644
index 00000000..9a4d3588
--- /dev/null
+++ b/front/src/components/Conversation/OhbDisclaimer.jsx
@@ -0,0 +1,41 @@
+import { Link } from "react-router-dom";
+import { Trans, useTranslation } from "react-i18next";
+
+// Extensive OHB usage disclaimer, shown below the input box on every new
+// (empty) conversation.
+export default function OhbDisclaimer() {
+ const { t } = useTranslation();
+
+ const sections = [
+ { title: t("alert.ohb_disclaimer.s1_title"), body: t("alert.ohb_disclaimer.s1") },
+ { title: t("alert.ohb_disclaimer.s2_title"), body: t("alert.ohb_disclaimer.s2") },
+ { title: t("alert.ohb_disclaimer.s3_title"), body: t("alert.ohb_disclaimer.s3") },
+ ];
+
+ return (
+
+
+
+
+ {sections.map((section) => (
+
+ {section.title} {section.body}
+
+ ))}
+
+
+
+ {" "}
+
+
+
+
+
+
+
+ );
+}
diff --git a/front/src/components/Footer/CollapsibleFooter.tsx b/front/src/components/Footer/CollapsibleFooter.tsx
index eda16010..cd1956f8 100644
--- a/front/src/components/Footer/CollapsibleFooter.tsx
+++ b/front/src/components/Footer/CollapsibleFooter.tsx
@@ -59,7 +59,17 @@ export default function CollapsibleFooter({ className }: { className?: string })
-
+ {/* Help (static PDF) */}
+
+
+
+
+
+
{/* Right section */}
diff --git a/front/src/components/Header/AnnouncementBar.jsx b/front/src/components/Header/AnnouncementBar.jsx
index e31c583d..c4cd66ad 100644
--- a/front/src/components/Header/AnnouncementBar.jsx
+++ b/front/src/components/Header/AnnouncementBar.jsx
@@ -3,12 +3,13 @@ import { useSelector, useDispatch } from "react-redux";
import { Trans } from "react-i18next";
import { X } from "lucide-react";
import { closeAnnouncement, selectCountAnnouncement } from "../../Redux/reducers/interfaceSettingsSlice";
+import config from "../../config";
export default function AnnouncementBar() {
const dispatch = useDispatch();
const closeCount = useSelector(selectCountAnnouncement);
const [showAnnouncement, setShowAnnouncement] = useState(true);
- const announcement = import.meta.env.VITE_ANNOUNCEMENT;
+ const announcement = config.announcement;
if (!announcement || announcement === "") return;
useEffect(() => {
diff --git a/front/src/components/Header/Header.tsx b/front/src/components/Header/Header.tsx
index e9efe50b..623e7080 100644
--- a/front/src/components/Header/Header.tsx
+++ b/front/src/components/Header/Header.tsx
@@ -6,7 +6,6 @@ import { toggleSidebar } from "../../Redux/reducers/interfaceSettingsSlice";
import SettingsWrapper from "../SettingsPanel/SettingsWrapper";
import SettingsButton from "./SettingsButton";
import ModelSelectorWrapper from "./ModelSelectorWrapper";
-import ModelSelector from "./ModelSelector";
import WarningExternalModel from "./WarningExternalModel";
import AnnouncementBar from "./AnnouncementBar";
@@ -45,10 +44,7 @@ function Header({ className, localState, setLocalState, modelsData, userData })
{/* External Model Warning */}
{/* Settings Button */}
-
+
diff --git a/front/src/components/Header/LogoContainer.jsx b/front/src/components/Header/LogoContainer.jsx
index 64f9cc07..264833ed 100644
--- a/front/src/components/Header/LogoContainer.jsx
+++ b/front/src/components/Header/LogoContainer.jsx
@@ -1,8 +1,12 @@
import Logo from "../../assets/logos/chat_ai.svg";
import LogoSmall from "../../assets/logos/chat_ai_small.ico"
import { Link } from "react-router-dom";
+import config from "../../config";
export default function LogoContainer({ isMobile = false }) {
+ if (config.overrides?.branding === "gwdg") {
+ return null;
+ }
return !isMobile ? (
diff --git a/front/src/components/Header/MPGHeader.tsx b/front/src/components/Header/MPGHeader.tsx
new file mode 100644
index 00000000..d02fa6d1
--- /dev/null
+++ b/front/src/components/Header/MPGHeader.tsx
@@ -0,0 +1,43 @@
+export default function MPGHeader() {
+ /*
+ Warning: This is a concept and not fully implemented, e.g. it is not translated.
+ */
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
KI-Assistent zum Organisationshandbuch
+
Anweisungen und Arbeitshilfen
+
+
+ );
+}
diff --git a/front/src/components/Header/ModelSelectorIcon.tsx b/front/src/components/Header/ModelSelectorIcon.tsx
new file mode 100644
index 00000000..747ccdea
--- /dev/null
+++ b/front/src/components/Header/ModelSelectorIcon.tsx
@@ -0,0 +1,339 @@
+import { memo, useEffect, useRef, useState } from 'react';
+import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
+import { useTranslation } from "react-i18next";
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import {
+ faArrowUpAZ,
+ faBookOpen,
+ faBrain,
+ faCircleInfo,
+ faChevronDown,
+ faImage,
+ faMagnifyingGlass,
+ faMicrophone,
+ faVideo,
+ faRobot,
+ faCog
+} from '@fortawesome/free-solid-svg-icons';
+import type { ModelInfo } from '../../types/models';
+import Tooltip from "../Others/Tooltip";
+import DemandIndicator from "./DemandIndicator";
+import { Settings } from 'lucide-react';
+
+const sortOptions = [
+ { value: "name-asc", label: "Name (A→Z)" },
+ { value: "name-desc", label: "Name (Z→A)" },
+];
+
+interface ModelSelectorIconProps {
+ selectedModel: ModelInfo | null;
+ modelsData: ModelInfo[];
+ onChange: (model: ModelInfo) => void;
+ inHeader?: boolean;
+}
+
+function ModelSelectorIcon({
+ selectedModel,
+ modelsData,
+ onChange,
+ inHeader = false
+}: ModelSelectorIconProps) {
+
+ const { t } = useTranslation();
+ const [dropdownOpen, setDropdownOpen] = useState(false);
+ const [searchQuery, setSearchQuery] = useState("");
+ const [sortBy, setSortBy] = useState("name-asc");
+ const [buttonPosition, setButtonPosition] = useState({ top: 0, left: 0, height: 56 });
+ const dropdownRef = useRef
(null);
+ const buttonRef = useRef(null);
+
+ // Update button position when dropdown opens
+ useEffect(() => {
+ if (dropdownOpen && buttonRef.current) {
+ const rect = buttonRef.current.getBoundingClientRect();
+ const dropdownHeight = 400; // Estimated max height
+ setButtonPosition({
+ top: rect.top,
+ left: rect.left,
+ height: rect.height,
+ });
+ }
+ }, [dropdownOpen]);
+
+ const handleToggleDropdown = (e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ console.log('Toggling dropdown, current state:', dropdownOpen);
+ setDropdownOpen(!dropdownOpen);
+ };
+
+ // Dropdown close on click outside logic
+ useEffect(() => {
+ function handleClickOutside(event: MouseEvent) {
+ if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
+ setDropdownOpen(false);
+ }
+ }
+ document.addEventListener("mousedown", handleClickOutside);
+ return () => {
+ document.removeEventListener("mousedown", handleClickOutside);
+ };
+ }, []);
+
+ const filteredModelsList = (modelsData || []).filter((model) => {
+ const q = searchQuery.trim().toLowerCase();
+ if (!q) return true;
+
+ const inputOutput = [...(model.input || []), ...(model.output || [])].join(" ").toLowerCase();
+ return (
+ model.name.toLowerCase().includes(q) ||
+ model.id.toLowerCase().includes(q) ||
+ inputOutput.includes(q)
+ );
+ }).sort((a, b) => {
+ if (sortBy === "name-asc") {
+ return a.name.localeCompare(b.name);
+ } else if (sortBy === "name-desc") {
+ return b.name.localeCompare(a.name);
+ }
+ return 0;
+ });
+
+ return (
+
+ {/* Icon Trigger */}
+
+
+ {selectedModel?.status === "ready" && (
+
+ )}
+
+
+ {/* Dropdown Panel */}
+ {dropdownOpen && (
+
+ {/* Header Section */}
+
+
+ {/* Search and Sort Controls */}
+
+
+ {/* Search Input */}
+
+
+ setSearchQuery(e.target.value)}
+ type="text"
+ placeholder="Search models..."
+ autoComplete="off"
+ className="w-full rounded-lg border border-slate-200 dark:border-gray-500
+ pl-8 pr-3 py-1.5 text-sm
+ focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/30
+ bg-white dark:bg-bg_secondary_dark
+ text-slate-700 dark:text-slate-200"
+ />
+
+
+ {/* Sort Dropdown */}
+
+
+
+ {sortOptions.find((opt) => opt.value === sortBy)?.label || "Sort"}
+
+
+
+
+
+
+
+
+
+ {sortOptions.map((option) => (
+
+ {({ close }) => (
+ { setSortBy(option.value); close() }}
+ className="w-full px-3 py-1.5 text-left text-sm
+ text-slate-700 dark:text-slate-200
+ hover:bg-slate-100 dark:hover:bg-slate-700
+ focus:outline-none"
+ >
+ {option.label}
+
+ )}
+
+ ))}
+
+
+
+
+
+
+ {/* Model List */}
+
+ {filteredModelsList.length === 0 ? (
+
+ No models found
+
+ ) : (
+ filteredModelsList.map((model) => (
+
{
+ onChange(model);
+ setDropdownOpen(false);
+ }}
+ className={`
+ w-full flex items-center justify-between gap-2
+ mb-1 px-3 py-2 rounded-lg
+ transition-colors duration-150
+ ${selectedModel?.id === model.id
+ ? "bg-indigo-50 dark:bg-indigo-900/30 border border-indigo-200 dark:border-indigo-700"
+ : "hover:bg-slate-100 dark:hover:bg-slate-700 border border-transparent"
+ }
+ `}
+ >
+
+
+ {model.input?.includes("image") && (
+
+
+
+ )}
+ {model.input?.includes("video") && (
+
+
+
+ )}
+ {model.input?.includes("audio") && (
+
+
+
+ )}
+ {model.input?.includes("arcana") && (
+
+
+
+ )}
+ {model.output?.includes("thought") && (
+
+
+
+ )}
+
+
+ ))
+ )}
+
+
+ {/* Current Model Display */}
+ {selectedModel && (
+
+
+ Current: {selectedModel.name}
+
+
+ {selectedModel.input?.includes("image") && (
+
+
+
+ )}
+ {selectedModel.input?.includes("video") && (
+
+
+
+ )}
+ {selectedModel.input?.includes("audio") && (
+
+
+
+ )}
+ {selectedModel.input?.includes("arcana") && (
+
+
+
+ )}
+ {selectedModel.output?.includes("thought") && (
+
+
+
+ )}
+
+
+ )}
+
+ )}
+
+ );
+}
+
+export default memo(ModelSelectorIcon);
\ No newline at end of file
diff --git a/front/src/components/Header/ModelSelectorWrapper.tsx b/front/src/components/Header/ModelSelectorWrapper.tsx
index aaced964..12446c3f 100644
--- a/front/src/components/Header/ModelSelectorWrapper.tsx
+++ b/front/src/components/Header/ModelSelectorWrapper.tsx
@@ -1,21 +1,31 @@
-import { memo, useEffect, useState, useRef } from 'react'
+import { memo, useEffect, useState } from 'react'
import ModelSelectorSimple from "./ModelSelectorSimple";
import ModelSelectorExtended from "./ModelSelectorExtended";
+import ModelSelectorIcon from "./ModelSelectorIcon";
import { useModal } from '../../modals/ModalContext';
import type { ModelInfo } from '../../types/models';
-function ModelSelectorWrapper({modelsData, localState, setLocalState, inHeader = false}: {modelsData: [ModelInfo], localState: any, setLocalState: any, inHeader: boolean}) {
+interface ModelSelectorWrapperProps {
+ modelsData: ModelInfo[];
+ localState: any;
+ setLocalState: any;
+ inHeader?: boolean;
+ iconMode?: boolean;
+}
+
+function ModelSelectorWrapper({modelsData, localState, setLocalState, inHeader = false, iconMode = false}: ModelSelectorWrapperProps) {
/*
render either ModelSelectorSimple or ModelSelectorExtended depending if modelsList contains models with extended==true
*/
const { openModal } = useModal();
-
+
const currentModelId = localState?.settings?.model?.id;
const [selectedModel, setSelectedModel] = useState(null);
- //const selectedModel = modelsData ? modelsData.find(model => model.id === currentModelId) || modelsData[0] || null : null;
- const hasExtendedModels = modelsData?.[0]?.description !== undefined;
+ // Ensure modelsData is always an array
+ const safeModelsData = Array.isArray(modelsData) ? modelsData : [];
+ const hasExtendedModels = safeModelsData.length > 0 && 'description' in safeModelsData[0];
function setModel(newModel: ModelInfo) {
if (newModel?.status === "offline") {
@@ -34,26 +44,28 @@ function ModelSelectorWrapper({modelsData, localState, setLocalState, inHeader =
// currentModelId has changed indirectly
useEffect(() => {
if(!currentModelId) return;
- if(modelsData.length === 0) return;
+ if(safeModelsData.length === 0) return;
- const foundModel = modelsData.find(
+ const foundModel = safeModelsData.find(
(model) => model.id === currentModelId
);
if (foundModel){
setModel(foundModel);
} else {
// fallback to first model
- setModel(modelsData[0]);
+ setModel(safeModelsData[0]);
}
- }, [currentModelId, modelsData]);
+ }, [currentModelId, safeModelsData]);
return (
<>
{
- hasExtendedModels ?
-
- :
-
+ iconMode ? (
+
+ ) : hasExtendedModels ?
+
+ :
+
}
>
)
diff --git a/front/src/components/Prompt/Prompt.jsx b/front/src/components/Prompt/Prompt.jsx
index bb32f6cf..61d67bbc 100644
--- a/front/src/components/Prompt/Prompt.jsx
+++ b/front/src/components/Prompt/Prompt.jsx
@@ -1,4 +1,4 @@
-import { useState, useEffect} from "react";
+import { useState, useEffect } from "react";
import AbortButton from "./AbortButton";
import SendButton from "./SendButton";
@@ -9,27 +9,31 @@ import AttachButton from "./AttachButton";
import AttachMediaButton from "./AttachMediaButton";
import ClearButton from "./ClearButton";
import PromptTextArea from "./PromptTextArea";
+import ModelSelectorWrapper from "../Header/ModelSelectorWrapper";
import { useSendMessage } from "../../hooks/useSendMessage";
import { useDebounce } from "../../hooks/useDebounce";
+import config from "../../config";
+
export default function Prompt({
localState,
setLocalState,
-}) {
+ modelsData,
+}) {
const sendMessage = useSendMessage();
const [shouldSend, setShouldSend] = useState(false);
const [ignoreChanges, setIgnoreChanges] = useState(false);
const lastMessage = localState.messages[localState.messages.length - 1];
- if (lastMessage?.content == undefined){
+ if (lastMessage?.content == undefined) {
// return to a valid conversation
- localState.messages = [{"content" : [{"text" : ""}]}];
+ localState.messages = [{ "content": [{ "text": "" }] }];
}
const [prompt, setPrompt] = useState(lastMessage?.content[0]?.text || "");
//const prompt = localState.messages[localState.messages.length - 1].content[0]?.text || "";
const attachments = lastMessage.content.slice(1);
-
+
// Update partial local state while preserving other values
const savePrompt = (nextPrompt = prompt, { clearChoices = false } = {}) => {
setIgnoreChanges(true);
@@ -37,11 +41,11 @@ export default function Prompt({
const messages = [...prev.messages]; // shallow copy
messages[messages.length - 1] = {
role: "user",
- content: [ { // Replace first content item
- type: "text",
- text: nextPrompt
- }, // Keep other content items
- ...prev.messages[messages.length - 1].content.slice(1)
+ content: [{ // Replace first content item
+ type: "text",
+ text: nextPrompt
+ }, // Keep other content items
+ ...prev.messages[messages.length - 1].content.slice(1)
]
};
return {
@@ -55,7 +59,7 @@ export default function Prompt({
// Effect, watch for changes to prompt in localState
useEffect(() => {
if (shouldSend) {
- sendMessage({localState, setLocalState});
+ sendMessage({ localState, setLocalState });
setShouldSend(false);
setIgnoreChanges(false);
setPrompt("");
@@ -74,74 +78,83 @@ export default function Prompt({
setPrompt(e.target.value);
debouncedSave();
};
-
+
// Handle form submission with prompt and files
const handleSend = async (event, nextPrompt) => {
- event.preventDefault();
- const promptToSend = typeof nextPrompt === "string" ? nextPrompt : prompt;
- if (promptToSend?.trim() === "" && attachments.length === 0) return;
- debouncedSave.cancel();
- savePrompt(promptToSend, { clearChoices: true });
- setShouldSend(true);
+ event.preventDefault();
+ const promptToSend = typeof nextPrompt === "string" ? nextPrompt : prompt;
+ if (promptToSend?.trim() === "" && attachments.length === 0) return;
+ debouncedSave.cancel();
+ savePrompt(promptToSend, { clearChoices: true });
+ setShouldSend(true);
};
-
+
return (
- {/* Attachments Container */}
-
+
+ {/* Prompt Text Area */}
+
-
- {/* Prompt Text Area */}
-
+ { /* Model selector next to clear button */}
+ {config?.overrides?.ui?.showModelSelectorInChatArea && (
+ < ModelSelectorWrapper
+ localState={localState}
+ setLocalState={setLocalState}
+ modelsData={modelsData}
+ iconMode={true}
+ />
+ )}
+ {/* Clear Button on the left */}
+
- {/* Buttons Section */}
-
- {/* Clear Button on the left */}
-
+ {/* Settings Button */}
+ {/* */}
+ {/* Attach Button */}
+
- {/* Buttons on the right */}
-
- {/* Settings Button */}
- {/*
*/}
- {/* Attach Button */}
-
- {/* Attach Media Button */}
- {/*
*/}
- {/* Mic Button */}
-
- {/* Abort button (when loading) */}
-
- {/* If not loading, show send button */}
-
-
+ {/* Mic Button */}
+
+ {/* Abort button (when loading) */}
+
+ {/* If not loading, show send button */}
+
+
);
}
diff --git a/front/src/components/SettingsPanel/SettingsContent.jsx b/front/src/components/SettingsPanel/SettingsContent.jsx
index 28c7cb27..cf175bbd 100644
--- a/front/src/components/SettingsPanel/SettingsContent.jsx
+++ b/front/src/components/SettingsPanel/SettingsContent.jsx
@@ -41,6 +41,7 @@ import MCPContainer from "./MCPContainer";
import ToolsContainer from "./ToolsContainer";
import VideoList from "./VideoList";
import ShortcutTooltip from "../Sidebar/ShortcutTooltip";
+import config from "../../config";
const sleep = (delay) => new Promise((resolve) => setTimeout(resolve, delay));
@@ -53,8 +54,8 @@ const SettingsPanel = ({ localState, setLocalState, userData, modelsData }) => {
const userSettings = useSelector(selectUserSettings);
const settings = localState.settings;
const tools = settings?.tools || {};
- const toolsModule = import.meta.env.VITE_MODULE_TOOLS === "true";
- const choicesModule = import.meta.env.VITE_MODULE_CHOICES === "true";
+ const toolsModule = config.modules?.tools;
+ const choicesModule = config.modules?.choices;
const showArcanaBox = !!settings?.enable_tools && !!tools.arcana;
const showMCPBox = !!settings?.enable_tools && !!tools.mcp;
const showVideoList = !!tools.video_generation;
diff --git a/front/src/components/Sidebar/AiServicesMenu.tsx b/front/src/components/Sidebar/AiServicesMenu.tsx
index 4c74cdf2..a26ce48e 100644
--- a/front/src/components/Sidebar/AiServicesMenu.tsx
+++ b/front/src/components/Sidebar/AiServicesMenu.tsx
@@ -6,6 +6,7 @@ import ImageAiLogo from '../../assets/logos/image-ai.svg'
import VoiceAiLogo from '../../assets/logos/voice-ai.svg'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronRight } from '@fortawesome/free-solid-svg-icons'
+import config from '../../config'
const items = [
{
@@ -35,6 +36,10 @@ const items = [
]
export default function AiServicesMenu() {
+ if (config.overrides?.branding === "mpg") {
+ return null;
+ }
+
return (
-
- {/* Import Conversation button */}
-
+ {!(config.overrides?.ui?.hideImportConversationButton && config.overrides?.ui?.hideImportPersonaButton) && (
+
+ {/* Import Conversation button */}
+ {!config.overrides?.ui?.hideImportConversationButton && }
- {/* Import persona from Github */}
-
- {
- openModal("importPersona");
- }}
- className={`cursor-pointer p-1 hover:bg-green-50 dark:hover:bg-green-900/30 hover:text-green-600 dark:hover:text-green-400 rounded-2xl transition-all duration-200 flex items-center justify-center`}
- aria-label={t("sidebar.import_persona")}
- >
-
-
-
-
+ {/* Import persona from Github */}
+ {!config.overrides?.ui?.hideImportPersonaButton && (
+
+ {
+ openModal("importPersona");
+ }}
+ className={`cursor-pointer p-1 hover:bg-green-50 dark:hover:bg-green-900/30 hover:text-green-600 dark:hover:text-green-400 rounded-2xl transition-all duration-200 flex items-center justify-center`}
+ aria-label={t("sidebar.import_persona")}
+ >
+
+
+
+ )}
+
+ )}
);
}
diff --git a/front/src/components/Sidebar/ExportConversationModal.jsx b/front/src/components/Sidebar/ExportConversationModal.jsx
index bc44f686..7c2e7fbd 100644
--- a/front/src/components/Sidebar/ExportConversationModal.jsx
+++ b/front/src/components/Sidebar/ExportConversationModal.jsx
@@ -4,13 +4,38 @@ import { Trans } from "react-i18next";
import BaseModal from "../../modals/BaseModal";
import icon_file_json from "../../assets/icons/file_json.svg";
import icon_file_pdf from "../../assets/icons/file_pdf.svg";
+import icon_file_docx from "../../assets/icons/file_docx.svg";
import icon_file_text from "../../assets/icons/file_text.svg";
import { getConversation, loadFile, loadFileMeta } from "../../db";
import { useToast } from "../../hooks/useToast";
import { jsPDF } from "jspdf";
+import {
+ Document,
+ Packer,
+ Paragraph,
+ TextRun,
+ HeadingLevel,
+ AlignmentType,
+ ImageRun,
+ ShadingType,
+ Header,
+ Footer,
+ PageNumber,
+} from "docx";
import Logo from "../../assets/logos/chat_ai.png"
import { processContentItems } from "../../utils/sendMessage";
+// Matches the delimiter that separates an assistant's answer from the raw
+// Arcana/RAG reference snippets appended after it (mirrors
+// MarkdownRenderer's separateContentAndReferences split).
+const ARCANA_REFERENCES_RE = /(^|\n)[-\s]{5,}\n\s*References\s*:\s*\n/i;
+
+// Matches closed ... reasoning blocks emitted by reasoning
+// models, in both literal and HTML-escaped (<think>) form (mirrors the
+// two variants MarkdownRenderer's splitThink handles).
+const THINKING_BLOCK_RE = /]*>[\s\S]*?<\/think>/gi;
+const THINKING_BLOCK_ESCAPED_RE = /<think\b[^&]*>[\s\S]*?<\/think>/gi;
+
export default function ExportConversationModal({
isOpen,
onClose,
@@ -20,8 +45,10 @@ export default function ExportConversationModal({
const [exportFormat, setExportFormat] = useState("json");
const [exportSettings, setExportSettings] = useState(true);
const [exportArcana, setExportArcana] = useState(false);
+ const [exportArcanaRag, setExportArcanaRag] = useState(false);
const [exportMcpServers, setExportMcpServers] = useState(false);
const [exportFiles, setExportFiles] = useState(false);
+ const [exportThinking, setExportThinking] = useState(false);
const [containsFiles, setContainsFiles] = useState(true); // TODO set dynamically
const [conversation, setConversation] = useState(null);
@@ -63,6 +90,16 @@ export default function ExportConversationModal({
: Array.isArray(mcpServers)
? mcpServers.length > 0
: false;
+ const hasArcanaReferences =
+ Array.isArray(messages) &&
+ messages.some(
+ (m) => typeof m?.content === "string" && ARCANA_REFERENCES_RE.test(m.content)
+ );
+ const hasThinking =
+ Array.isArray(messages) &&
+ messages.some(
+ (m) => typeof m?.content === "string" && /(?:<|<)think\b/i.test(m.content)
+ );
// Function to generate timestamped filename for exports
const generateFileName = (extension) => {
@@ -77,6 +114,22 @@ export default function ExportConversationModal({
};
// Function to process messages into export format
+ // Strip reasoning blocks and/or raw Arcana RAG reference snippets from a
+ // message's text content, depending on which export checkboxes are set
+ const filterMessageContent = (text) => {
+ if (typeof text !== "string") return text;
+ let result = text;
+ if (!exportThinking)
+ result = result
+ .replace(THINKING_BLOCK_RE, "")
+ .replace(THINKING_BLOCK_ESCAPED_RE, "");
+ if (!exportArcanaRag) {
+ const m = result.match(ARCANA_REFERENCES_RE);
+ if (m) result = result.slice(0, m.index + (m[1] ? m[1].length : 0));
+ }
+ return result.trim();
+ };
+
const processMessages = async () => {
let processedMessages = [];
if (!Array.isArray(messages)) return processedMessages;
@@ -108,6 +161,17 @@ export default function ExportConversationModal({
}
}
+ // Apply reasoning/Arcana-RAG content filters
+ if (typeof processedMessage.content === "string") {
+ processedMessage.content = filterMessageContent(processedMessage.content);
+ } else if (Array.isArray(processedMessage.content)) {
+ processedMessage.content = processedMessage.content.map((item) =>
+ item?.type === "text"
+ ? { ...item, text: filterMessageContent(item.text) }
+ : item
+ );
+ }
+
// Check if empty user prompt at the end
if (i === messages.length-1
&& processedMessage.role === "user"
@@ -504,6 +568,219 @@ export default function ExportConversationModal({
}
};
+ // Decode an embedded image data URL into raw bytes for docx's ImageRun
+ const decodeImageDataUrl = (dataUrl) => {
+ const match = dataUrl.match(/^data:image\/(\w+);base64,(.+)$/);
+ if (!match) return null;
+ let [, type, base64] = match;
+ type = type === "jpeg" ? "jpg" : type;
+ if (!["jpg", "png", "gif", "bmp"].includes(type)) return null;
+ const binary = atob(base64);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
+ return { type, data: bytes };
+ };
+
+ // Export conversation as DOCX
+ const exportDOCX = async () => {
+ try {
+ const COLORS = {
+ ROLE: "0066CC",
+ CODE_BG: "F0F0F0",
+ HEADER_DATE: "969696",
+ };
+
+ // Turn a message's text into paragraphs, rendering fenced code blocks
+ // with a monospace font and a shaded background
+ const buildContentParagraphs = (content) => {
+ const paragraphs = [];
+ const parts = content.split(/(```[\s\S]+?```)/);
+ for (const part of parts) {
+ if (!part) continue;
+ if (part.startsWith("```")) {
+ const [, language, code] =
+ part.match(/```(\w+)?\n?([\s\S]+?)```/) || [];
+ if (code) {
+ if (language) {
+ paragraphs.push(
+ new Paragraph({
+ children: [new TextRun({ text: language, italics: true })],
+ })
+ );
+ }
+ code
+ .trim()
+ .split("\n")
+ .forEach((line) => {
+ paragraphs.push(
+ new Paragraph({
+ shading: {
+ type: ShadingType.SOLID,
+ color: COLORS.CODE_BG,
+ fill: COLORS.CODE_BG,
+ },
+ children: [
+ new TextRun({ text: line || " ", font: "Courier New" }),
+ ],
+ })
+ );
+ });
+ continue;
+ }
+ }
+ // Regular text, preserving line breaks within the paragraph
+ const lines = part.split("\n");
+ paragraphs.push(
+ new Paragraph({
+ children: lines.flatMap((line, index) =>
+ index === 0
+ ? [new TextRun(line)]
+ : [new TextRun({ text: line, break: 1 })]
+ ),
+ })
+ );
+ }
+ return paragraphs;
+ };
+
+ // Process Messages
+ let processedMessages = await processMessages();
+
+ const children = [
+ new Paragraph({
+ text: conversation?.title || "Chat AI Conversation",
+ heading: HeadingLevel.TITLE,
+ }),
+ ];
+
+ // Process each message in conversation
+ for (const entry of processedMessages) {
+ children.push(
+ new Paragraph({
+ spacing: { before: 200 },
+ children: [
+ new TextRun({ text: `${entry.role}:`, bold: true, color: COLORS.ROLE }),
+ ],
+ })
+ );
+
+ if (typeof entry.content === "string") {
+ children.push(...buildContentParagraphs(entry.content));
+ } else if (Array.isArray(entry.content) && exportFiles) {
+ // Handle mixed content (text and images)
+ for (const item of entry.content) {
+ if (item.type === "text") {
+ children.push(...buildContentParagraphs(item.text));
+ } else if (item.type === "image_url") {
+ const image = item.image_url?.url?.startsWith("data:image")
+ ? decodeImageDataUrl(item.image_url.url)
+ : null;
+ if (image) {
+ children.push(
+ new Paragraph({
+ children: [
+ new ImageRun({
+ type: image.type,
+ data: image.data,
+ transformation: { width: 200, height: 150 },
+ }),
+ ],
+ })
+ );
+ } else {
+ children.push(new Paragraph("[Invalid image format]"));
+ }
+ }
+ }
+ } else {
+ // Handle unavailable content
+ children.push(new Paragraph("Content unavailable"));
+ }
+ }
+
+ // Add settings section if enabled
+ if (exportSettings) {
+ let settings = processSettings();
+ children.push(
+ new Paragraph({
+ spacing: { before: 400 },
+ heading: HeadingLevel.HEADING_2,
+ text: "Conversation settings",
+ })
+ );
+ children.push(new Paragraph(`title: ${conversation?.title}`));
+ children.push(new Paragraph(`model: ${settings?.model}`));
+ children.push(new Paragraph(`model name: ${settings?.["model-name"]}`));
+ children.push(new Paragraph(`temperature: ${settings?.temperature}`));
+ children.push(new Paragraph(`top_p: ${settings?.top_p}`));
+
+ if (exportArcana && isArcanaSupported && settings?.arcana?.id) {
+ children.push(new Paragraph(`Arcana ID: ${settings.arcana.id}`));
+ }
+
+ if (exportMcpServers && hasMcpServers && settings?.mcp_servers) {
+ const mcpServersText = Array.isArray(settings.mcp_servers)
+ ? settings.mcp_servers.join(", ")
+ : settings.mcp_servers;
+ children.push(new Paragraph(`MCP server: ${mcpServersText}`));
+ }
+ }
+
+ const doc = new Document({
+ creator: "Chat AI",
+ title: conversation?.title || "Chat AI Conversation",
+ subject: "History",
+ description: "History",
+ keywords: "AI-Generated",
+ sections: [
+ {
+ headers: {
+ default: new Header({
+ children: [
+ new Paragraph({
+ alignment: AlignmentType.RIGHT,
+ children: [
+ new TextRun({
+ text: new Date().toLocaleDateString(),
+ color: COLORS.HEADER_DATE,
+ }),
+ ],
+ }),
+ ],
+ }),
+ },
+ footers: {
+ default: new Footer({
+ children: [
+ new Paragraph({
+ alignment: AlignmentType.CENTER,
+ children: [
+ new TextRun({
+ children: ["Page ", PageNumber.CURRENT, " of ", PageNumber.TOTAL_PAGES],
+ }),
+ ],
+ }),
+ ],
+ }),
+ },
+ children,
+ },
+ ],
+ });
+
+ // Create and download DOCX file
+ const blob = await Packer.toBlob(doc);
+ const link = document.createElement("a");
+ link.href = URL.createObjectURL(blob);
+ link.download = generateFileName("docx");
+ link.click();
+ URL.revokeObjectURL(link.href);
+ } catch (error) {
+ console.log(error)
+ notifyError("An error occurred while exporting to DOCX");
+ }
+ };
+
// Handle Format Change
const handleFormatChange = (format) => {
setExportFormat(format);
@@ -516,6 +793,8 @@ export default function ExportConversationModal({
await exportJSON();
} else if (exportFormat === "pdf") {
await exportPDF();
+ } else if (exportFormat === "docx") {
+ await exportDOCX();
} else if (exportFormat === "text") {
await exportTextFile();
}
@@ -525,6 +804,7 @@ export default function ExportConversationModal({
const exportOptions = [
{ id: "json", icon: icon_file_json, label: "export_conversation.json" },
{ id: "pdf", icon: icon_file_pdf, label: "export_conversation.pdf" },
+ { id: "docx", icon: icon_file_docx, label: "export_conversation.docx" },
{ id: "text", icon: icon_file_text, label: "export_conversation.text" },
];
@@ -540,10 +820,18 @@ export default function ExportConversationModal({
setExportArcana(event.target.checked);
};
+ const toggleExportArcanaRag = (event) => {
+ setExportArcanaRag(event.target.checked);
+ };
+
const toggleExportMcpServers = (event) => {
setExportMcpServers(event.target.checked);
};
+ const toggleExportThinking = (event) => {
+ setExportThinking(event.target.checked);
+ };
+
return (
) : null}
+ {/* Arcana RAG reference snippets Export Option */}
+ {hasArcanaReferences ? (
+ <>
+ {exportArcanaRag && (
+
+
+
+ )}
+
+
+
+
+
+
+ >
+ ) : null}
+
{/* MCP servers Export Option */}
{hasMcpServers && exportSettings ? (
<>
@@ -669,6 +983,23 @@ export default function ExportConversationModal({
+ {/* Export thinking/reasoning blocks checkbox */}
+
+
+
+
+
+
+
{/* Export Button */}
{/* Bottom section */}
-
- {/* Import Conversation button */}
-
- {/* Import Persona button */}
- {
- openModal("importPersona");
- }}
- className={`cursor-pointer w-full bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 active:bg-gray-200 dark:active:bg-gray-600 text-black dark:text-white px-4 py-3 rounded-2xl flex items-center justify-center gap-2 text-xs font-medium touch-manipulation transition-colors`}
- style={{
- WebkitTapHighlightColor: "transparent",
- minHeight: "44px",
- }}
- >
-
-
-
-
-
+ {!(config.overrides?.ui?.hideImportConversationButton && config.overrides?.ui?.hideImportPersonaButton) && (
+
+ {/* Import Conversation button */}
+ {!config.overrides?.ui?.hideImportConversationButton && (
+
+ )}
+ {/* Import Persona button */}
+ {!config.overrides?.ui?.hideImportPersonaButton && (
+ {
+ openModal("importPersona");
+ }}
+ className={`cursor-pointer w-full bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 active:bg-gray-200 dark:active:bg-gray-600 text-black dark:text-white px-4 py-3 rounded-2xl flex items-center justify-center gap-2 text-xs font-medium touch-manipulation transition-colors`}
+ style={{
+ WebkitTapHighlightColor: "transparent",
+ minHeight: "44px",
+ }}
+ >
+
+
+
+
+ )}
+
+ )}
{/* MENU RENDERED OUTSIDE - PORTAL STYLE */}
diff --git a/front/src/components/Sidebar/SidebarContent.tsx b/front/src/components/Sidebar/SidebarContent.tsx
index 34ad1526..c47a3b4f 100644
--- a/front/src/components/Sidebar/SidebarContent.tsx
+++ b/front/src/components/Sidebar/SidebarContent.tsx
@@ -28,6 +28,7 @@ import ImportConversationButton from "./ImportConversationButton";
import AiServicesMenu from "./AiServicesMenu";
import ShortcutTooltip from "./ShortcutTooltip";
import { useToast } from "../../hooks/useToast";
+import config from "../../config";
const ALL_FOLDERS = "__all__";
@@ -286,11 +287,10 @@ export default function SidebarContent({
e.preventDefault();
handleFolderDrop(option.id);
}}
- className={`group flex items-center gap-2 rounded-2xl px-3 py-2 text-xs transition cursor-pointer border border-transparent ${
- isActive
+ className={`group flex items-center gap-2 rounded-2xl px-3 py-2 text-xs transition cursor-pointer border border-transparent ${isActive
? "bg-gray-100 dark:bg-gray-800 text-black dark:text-white shadow-sm"
: "text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-800/40"
- } ${isDropTarget ? "border-tertiary/60 bg-tertiary/5 dark:bg-tertiary/20" : ""}`}
+ } ${isDropTarget ? "border-tertiary/60 bg-tertiary/5 dark:bg-tertiary/20" : ""}`}
>
@@ -482,24 +482,24 @@ export default function SidebarContent({
-
-
-
-
-
-
-
- {(
+ style={{
+ WebkitTapHighlightColor: "transparent",
+ minHeight: "44px",
+ }}
+ >
+
+
+
+
+
+
+ {(
@@ -519,61 +519,60 @@ export default function SidebarContent({
*/}
)}
-
+
-
+
{t("folders.title")}
@@ -611,20 +609,20 @@ export default function SidebarContent({
`}
>
- {renderFolderRow({
- id: ALL_FOLDERS,
- label: t("folders.all"),
- countKey: ALL_FOLDERS,
- })}
- {folders.map((folder) =>
- renderFolderRow({
- id: folder.id,
- label: folder.name,
- countKey: folder.id,
- canEdit: true,
- folder,
- })
- )}
+ {renderFolderRow({
+ id: ALL_FOLDERS,
+ label: t("folders.all"),
+ countKey: ALL_FOLDERS,
+ })}
+ {folders.map((folder) =>
+ renderFolderRow({
+ id: folder.id,
+ label: folder.name,
+ countKey: folder.id,
+ canEdit: true,
+ folder,
+ })
+ )}
@@ -661,11 +659,10 @@ export default function SidebarContent({
draggable
onDragStart={(event) => handleConversationDragStart(event, id)}
onDragEnd={handleConversationDragEnd}
- className={`group relative px-3 py-3 rounded-2xl touch-manipulation border border-transparent ${
- isActive
+ className={`group relative px-3 py-3 rounded-2xl touch-manipulation border border-transparent ${isActive
? "bg-gray-100 dark:bg-gray-800 text-black dark:text-white shadow-sm"
: "text-black dark:text-white hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-all duration-100"
- } ${isDragging ? "border-tertiary/60 bg-tertiary/10 dark:bg-tertiary/20" : ""}`}
+ } ${isDragging ? "border-tertiary/60 bg-tertiary/10 dark:bg-tertiary/20" : ""}`}
data-current={isActive ? "true" : "false"}
style={{
WebkitTapHighlightColor: "transparent",
@@ -688,14 +685,13 @@ export default function SidebarContent({
{/* Dropdown Menu Button */}
(menuButtonRefs.current[id] = el)}
@@ -717,26 +713,30 @@ export default function SidebarContent({
{/* Bottom section */}
-
-
-
-
{
- openModal("importPersona");
- }}
- className={`cursor-pointer w-full bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 active:bg-gray-200 dark:active:bg-gray-600 text-black dark:text-white px-4 py-3 rounded-2xl flex items-center justify-center gap-2 text-xs font-medium touch-manipulation transition-colors`}
- style={{
- WebkitTapHighlightColor: "transparent",
- minHeight: "44px",
- }}
- >
-
-
-
-
-
+ {!(config.overrides?.ui?.hideImportConversationButton && config.overrides?.ui?.hideImportPersonaButton) && (
+
+
+ {!config.overrides?.ui?.hideImportConversationButton && }
+ {!config.overrides?.ui?.hideImportPersonaButton && (
+ {
+ openModal("importPersona");
+ }}
+ className={`cursor-pointer w-full bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 active:bg-gray-200 dark:active:bg-gray-600 text-black dark:text-white px-4 py-3 rounded-2xl flex items-center justify-center gap-2 text-xs font-medium touch-manipulation transition-colors`}
+ style={{
+ WebkitTapHighlightColor: "transparent",
+ minHeight: "44px",
+ }}
+ >
+
+
+
+
+
+ )}
+
-
+ )}
diff --git a/front/src/components/Sidebar/SidebarRail.tsx b/front/src/components/Sidebar/SidebarRail.tsx
index d55b0cdc..7a76bb95 100644
--- a/front/src/components/Sidebar/SidebarRail.tsx
+++ b/front/src/components/Sidebar/SidebarRail.tsx
@@ -34,6 +34,7 @@ import { useWindowSize } from "../../hooks/useWindowSize";
import ImportConversationButton from "./ImportConversationButton";
import { useModal } from "../../modals/ModalContext";
import ShortcutTooltip from "./ShortcutTooltip";
+import config from "../../config";
export default function SidebarRail({ localState, onOpen, handleNewConversation }: { localState: any, onOpen: () => void, handleNewConversation: (folderId?: string | null) => Promise }) {
@@ -68,25 +69,41 @@ export default function SidebarRail({ localState, onOpen, handleNewConversation
{/* Logo with chevron on hover */}
-
- {/* Logo */}
-
-
- {/* Chevron Button */}
-
- onOpen?.()}
- className="absolute h-10 w-10 inset-0 grid place-items-center rounded-xl transition duration-200 opacity-0 group-hover:opacity-100 hover:bg-blue-50 dark:hover:bg-blue-900/30 cursor-pointer"
- aria-label={t("sidebar.expand")}
- >
-
-
-
-
+ {config.overrides?.branding !== "mpg" && (
+
+ {/* Logo */}
+
+
+ {/* Chevron Button */}
+
+ onOpen?.()}
+ className="absolute h-10 w-10 inset-0 grid place-items-center rounded-xl transition duration-200 opacity-0 group-hover:opacity-100 hover:bg-blue-50 dark:hover:bg-blue-900/30 cursor-pointer"
+ aria-label={t("sidebar.expand")}
+ >
+
+
+
+
+ )}
+ {config.overrides?.branding === "mpg" && (
+
+ {/* Chevron Button visible by default for mpg branding */}
+
+ onOpen?.()}
+ className="h-10 w-10 grid place-items-center rounded-xl transition duration-200 hover:bg-blue-50 dark:hover:bg-blue-900/30 cursor-pointer"
+ aria-label={t("sidebar.expand")}
+ >
+
+
+
+
+ )}
{/** Actions */}
@@ -141,23 +158,27 @@ export default function SidebarRail({ localState, onOpen, handleNewConversation
-
- {/* Import Conversation button */}
-
-
- {/* Import persona from Github */}
-
- {
- openModal("importPersona");
- }}
- className={`cursor-pointer p-1 hover:bg-green-50 dark:hover:bg-green-900/30 hover:text-green-600 dark:hover:text-green-400 rounded-2xl transition-all duration-200 flex items-center justify-center`}
- aria-label={t("sidebar.import_persona")}
- >
-
-
-
-
+ {!(config.overrides?.ui?.hideImportConversationButton && config.overrides?.ui?.hideImportPersonaButton) && (
+
+ {/* Import Conversation button */}
+ {!config.overrides?.ui?.hideImportConversationButton && }
+
+ {/* Import persona from Github */}
+ {!config.overrides?.ui?.hideImportPersonaButton && (
+
+ {
+ openModal("importPersona");
+ }}
+ className={`cursor-pointer p-1 hover:bg-green-50 dark:hover:bg-green-900/30 hover:text-green-600 dark:hover:text-green-400 rounded-2xl transition-all duration-200 flex items-center justify-center`}
+ aria-label={t("sidebar.import_persona")}
+ >
+
+
+
+ )}
+
+ )}
diff --git a/front/src/config.ts b/front/src/config.ts
new file mode 100644
index 00000000..74eee349
--- /dev/null
+++ b/front/src/config.ts
@@ -0,0 +1,4 @@
+import type { FrontConfig } from "../../secrets/front.config";
+
+const config = __GLOBAL_CONFIG__;
+export default config;
\ No newline at end of file
diff --git a/front/src/i18n/de.js b/front/src/i18n/de.js
index ae637cce..ec17f228 100644
--- a/front/src/i18n/de.js
+++ b/front/src/i18n/de.js
@@ -143,6 +143,7 @@ export default {
terms: "Nutzungsbedingungen",
docs: "Doku",
privacy: "Datenschutz",
+ help: "Hilfe",
faq: "FAQ",
contact: "Kontakt",
about: "Über uns",
@@ -236,12 +237,14 @@ export default {
title: "Exportoptionen",
json: "JSON-Datei",
pdf: "PDF-Datei",
+ docx: "DOCX-Datei",
text: "TXT-Datei",
export: "Exportieren",
export_settings: "Modell und Optionen einbeziehen",
export_files: "Dateien einbeziehen",
export_arcana: "Arcana-Details einbeziehen",
export_mcp_servers: "MCP-Server einbeziehen",
+ export_thinking: "Denkprozess einbeziehen",
},
// Rename Conversation Modal
rename_conversation: {
@@ -376,6 +379,24 @@ export default {
note2: "und ihre Antworten sollten nicht als korrekt angesehen werden.",
note3: "Halluzination",
},
+ // OHB persistent short banner (replaces the hallucination warning)
+ ohb_banner:
+ "KI-Assistent: Erstinformation — keine verbindliche Auskunft. KI-Chatbots sind Hilfsmittel. Sie ersetzen keine fachliche Expertise oder kritische Prüfung. Antworten können fehlerhaft sein (Halluzinationsgefahr). Prüfen Sie stets die maßgeblichen internen Vorgaben und Rechtsgrundlagen (siehe Referenzen in KI-Antwort). Bei Zweifeln wenden Sie sich an die Fachexpert*innen Ihres MPI oder der Generalverwaltung. Unseren Helpdesk erreichen Sie unter support@maxit.mpg.de.",
+ // OHB extensive disclaimer (shown below the input box on every new conversation)
+ ohb_disclaimer: {
+ title:
+ "Wichtiger Hinweis zur Nutzung des KI-Assistenten im Organisationshandbuch (OHB)",
+ s1_title: "1. Zweck",
+ s1: "Dieser KI-Assistent unterstützt die Suche im OHB und dient der ersten Orientierung. Die Antworten werden automatisiert generiert und stellen keine verbindliche Rechtsauskunft dar.",
+ s2_title: "2. Halluzinationsrisiko",
+ s2: "KI-Systeme können fehlerhafte, unvollständige oder veraltete Informationen erzeugen. Dies geschieht, weil die Modelle auf statistischen Mustern und nicht auf verifiziertem Wissen basieren. Die generierten Antworten ersetzen nicht die Lektüre der Primärquellen im OHB — insbesondere der verbindlichen Anweisungen (HS0, HS1 und HS2) sowie der Gesamtbetriebsvereinbarungen (HS1-GBV) — einschließlich der dort referenzierten externen Vorgaben.",
+ s3_title: "3. Vorrang der Fachexpertise",
+ s3: "Bei Zweifeln oder Unklarheiten ist die Expertise durch die im OHB genannten themenverantwortlichen Fachabteilungen der Generalverwaltung bzw. die zuständigen Fachexpertinnen und -experten am jeweiligen MPI einzuholen. Auskünfte des KI-Assistenten sind diesen Fachauskünften gegenüber nachrangig. Allgemeine Fragen zu KI richten Sie bitte an das Digital Office (digitalization@gv.mpg.de), bei Problemen mit der Nutzung unterstützt Sie MaxIT (support@maxit.mpg.de). Übergreifende Fragen zum OHB beantwortet Ihnen der Stab Compliance (ohb@gv.mpg.de).",
+ s4_title: "4. Vertraulichkeit",
+ s4_pre: "Die Inhalte des OHB unterliegen der Schutzklasse „intern“. Die Verarbeitung erfolgt im Einklang mit den geltenden Datenschutzregelungen der MPG und der GWDG als Dienstleister von chat-ai ",
+ s4_link: "(Datenschutzerklärung)",
+ s4_post: ".",
+ },
// External models
settings_external:
"Diese Einstellungen wirken sich nicht auf externe (OpenAI) Modelle aus.",
diff --git a/front/src/i18n/en.js b/front/src/i18n/en.js
index 20485ae8..4ff70f86 100644
--- a/front/src/i18n/en.js
+++ b/front/src/i18n/en.js
@@ -142,6 +142,7 @@ export default {
terms: "Terms of Use",
docs: "Documentation",
privacy: "Privacy",
+ help: "Help",
faq: "FAQ",
contact: "Contact Us",
about: "About",
@@ -236,12 +237,14 @@ export default {
// File format
json: "JSON file",
pdf: "PDF file",
+ docx: "DOCX file",
text: "TXT file",
export: "Export",
export_settings: "Include model and options",
export_files: "Include files",
export_arcana: "Include Arcana details",
export_mcp_servers: "Include MCP server",
+ export_thinking: "Include reasoning process",
},
// Rename Conversation Modal
rename_conversation: {
@@ -375,6 +378,24 @@ export default {
note2: "and their responses should not be considered accurate.",
note3: "Hallucination",
},
+ // OHB persistent short banner (replaces the hallucination warning)
+ ohb_banner:
+ "AI assistant: initial guidance only — not a binding statement. AI chatbots are aids. They do not replace professional expertise or critical review. Responses may be incorrect (risk of hallucination). Always check the relevant internal rules and legal bases (see the references in the AI response). If in doubt, contact the subject-matter experts at your MPI or at the General Administration. You can reach our helpdesk at support@maxit.mpg.de.",
+ // OHB extensive disclaimer (shown below the input box on every new conversation)
+ ohb_disclaimer: {
+ title:
+ "Important information on using the AI assistant in the Organisational Handbook (OHB)",
+ s1_title: "1. Purpose",
+ s1: "This AI assistant helps you search the OHB and serves as an initial orientation. The answers are generated automatically and do not constitute binding legal advice.",
+ s2_title: "2. Risk of hallucination",
+ s2: "AI systems can produce incorrect, incomplete or outdated information. This happens because the models are based on statistical patterns rather than verified knowledge. The generated answers do not replace reading the primary sources in the OHB — in particular the binding instructions (HS0, HS1 and HS2) as well as the general works agreements (HS1-GBV) — including the external requirements referenced there.",
+ s3_title: "3. Precedence of professional expertise",
+ s3: "In case of doubt or ambiguity, expertise must be obtained from the responsible specialist departments of the General Administration named in the OHB, or from the competent subject-matter experts at the respective MPI. Information provided by the AI assistant are secondary to these expert opinions. Please direct general questions about AI to the Digital Office (digitalization@gv.mpg.de); for problems with usage, MaxIT (support@maxit.mpg.de) can help. Cross-cutting questions about the OHB are answered by the Compliance Office (ohb@gv.mpg.de).",
+ s4_title: "4. Confidentiality",
+ s4_pre: "The contents of the OHB are classified as “internal”. Processing takes place in accordance with the applicable data protection regulations of the MPG and of the GWDG as the service provider for chat-ai ",
+ s4_link: "(Privacy Policy)",
+ s4_post: ".",
+ },
// External models
settings_external:
"These settings will not affect external (OpenAI) models.",
diff --git a/front/src/utils/conversationUtils.js b/front/src/utils/conversationUtils.js
index 6db88a19..cc5b63c6 100644
--- a/front/src/utils/conversationUtils.js
+++ b/front/src/utils/conversationUtils.js
@@ -2,13 +2,14 @@ import { v4 as uuidv4 } from "uuid";
import { useSelector } from "react-redux";
import { getConversation, getFolder, listConversationMetas } from "../db";
import { processContentItems } from "./sendMessage";
+import config from "../config";
export const getDefaultSettings = (userSettings = {}) => {
// Get environment settings
let envSettings = {};
- if (import.meta.env.VITE_DEFAULT_SETTINGS) {
+ if (config.default) {
try {
- envSettings = JSON.parse(import.meta.env.VITE_DEFAULT_SETTINGS);
+ envSettings = config.default;
} catch (e) {
envSettings = {};
}
diff --git a/front/src/utils/sendMessage.jsx b/front/src/utils/sendMessage.jsx
index 25e86db9..63798d37 100644
--- a/front/src/utils/sendMessage.jsx
+++ b/front/src/utils/sendMessage.jsx
@@ -8,6 +8,7 @@ import generateChoiceProposal from "../apis/generateChoiceProposal";
import generateTitle from "../apis/generateTitle";
import { loadFile, loadFileMeta, saveFile, updateConversation, updateConversationMeta } from "../db";
import { getFileType, readFileAsBase64, readFileAsText } from "./attachments";
+import config from "../config";
// Text to be appended to system prompt for memories
const memoryExplanation = "The following list of memories was gathered by the system from previous conversations and may be irrelevant now. You may refer to relevant items only if justified to provide a more personalized and contextual response. Do not make any assumptions based on memories, instead focus on the user messages and requests:"
@@ -199,9 +200,9 @@ const sendMessage = async ({
try {
const isArcanaSupported = localState.settings.model?.input?.includes("arcana") || (localState.settings?.enable_tools && !!localState.settings.tools.arcana)
- const feedbackModule = import.meta.env.VITE_MODULE_FEEDBACK === "true";
- const toolsModule = import.meta.env.VITE_MODULE_TOOLS === "true";
- const choicesModule = import.meta.env.VITE_MODULE_CHOICES === "true";
+ const feedbackModule = config.modules?.feedback;
+ const toolsModule = config.modules?.tools;
+ const choicesModule = config.modules?.choices;
let finalConversationForState; // For local state updates
let conversationForAPI = await buildConversationForAPI(localState);
@@ -305,12 +306,17 @@ const sendMessage = async ({
async function getChatChunk(conversationId, messageId = null) {
let currentContent = [{"type": "text", "text": ""}];
let usage = null;
+ let references = null;
let process_block = "";
let inThinking = false;
let message_text = "";
for await (const chunk of chatCompletions(conversationForAPI, timeoutAPI)) {
const delta = chunk?.choices[0]?.delta;
if (chunk?.usage) usage = chunk.usage;
+ // Structured RAG references arrive as a top-level `references` field
+ if (Array.isArray(chunk?.references) && chunk.references.length > 0) {
+ references = chunk.references;
+ }
// Check if reasoning exists
if (delta?.reasoning) {
process_block += delta.reasoning;
@@ -528,7 +534,8 @@ const sendMessage = async ({
}
return {
answer: currentContent,
- usage
+ usage,
+ references
}
}
@@ -544,7 +551,8 @@ const sendMessage = async ({
usage = chatChunk?.usage;
meta = {
model: localState.settings.model?.name || localState.settings.model?.id || "",
- usage
+ usage,
+ references: chatChunk?.references || undefined
};
} catch (error) {
const errorType = error?.type || "Error";
diff --git a/front/src/vite-globals.d.ts b/front/src/vite-globals.d.ts
new file mode 100644
index 00000000..c6735d75
--- /dev/null
+++ b/front/src/vite-globals.d.ts
@@ -0,0 +1,7 @@
+import type { FrontConfig } from "../../secrets/front.config";
+
+declare global {
+ const __GLOBAL_CONFIG__: FrontConfig;
+}
+
+export {};
\ No newline at end of file
diff --git a/front/vite.config.js b/front/vite.config.js
deleted file mode 100644
index c3e97550..00000000
--- a/front/vite.config.js
+++ /dev/null
@@ -1,86 +0,0 @@
-import { defineConfig } from "vite";
-import react from "@vitejs/plugin-react";
-import tailwindcss from '@tailwindcss/vite'
-import fs from "fs";
-import path from "path";
-
-const ASSET_URL = process.env.ASSET_URL || "";
-const CONFIG_LOCATION = process.env.CONFIG_LOCATION || "../secrets/front.json";
-
-// Default port if config file is missing or invalid
-let port = 8080;
-
-try {
- // Read and parse the JSON file
- const config = JSON.parse(fs.readFileSync(CONFIG_LOCATION, "utf8"));
- console.log(`Config loaded from ${CONFIG_LOCATION}:`, config);
-
- // Extract the port from the config (ensure it's a valid number)
- if (typeof config.port === "number" && config.port > 0) {
- port = config.port;
- console.log("Port:", port);
- } else {
- console.warn(
- "Invalid port in config.json. Falling back to default port 8080."
- );
- }
-
- // Inject VITE_ variables into the environment
- for (const [key, value] of Object.entries(config)) {
- if (key == "modelsPath") {
- process.env["VITE_MODELS_ENDPOINT"] = value;
- console.log("Models path:", value);
- } else if (key == "backendPath") {
- process.env["VITE_BACKEND_ENDPOINT"] = value;
- console.log("Backend path:", value);
- } else if (key == "userDataPath") {
- process.env["VITE_USERDATA_ENDPOINT"] = value;
- console.log("User data path:", value);
- } else if (key == "default") {
- process.env["VITE_DEFAULT_SETTINGS"] = JSON.stringify(value);
- console.log("Default settings:", JSON.stringify(value));
- } else if (key == "titleGenerationModel") {
- process.env["VITE_TITLE_GENERATION_MODEL"] = value;
- console.log("Title generation model:", value);
- } else if (key == "memoryGenerationModel") {
- process.env["VITE_MEMORY_GENERATION_MODEL"] = value;
- console.log("Memory generation model:", value);
- } else if (key == "proposalGenerationModel") {
- process.env["VITE_PROPOSAL_GENERATION_MODEL"] = value;
- console.log("Proposal generation model:", value);
- } else if (key == "announcement") {
- process.env["VITE_ANNOUNCEMENT"] = value;
- console.log("Announcement:", value);
- } else if (key == "modules"){
- console.log("Modules:", JSON.stringify(value));
- try {
- process.env["VITE_MODULE_TOOLS"] = value?.tools || false;
- process.env["VITE_MODULE_FEEDBACK"] = value?.feedback || false;
- process.env["VITE_MODULE_CHOICES"] = value?.choices || false;
- process.env["VITE_MODULE_SPEECH"] = JSON.stringify(value?.speech) || false;
- } catch (e) {
- console.log("Error while parsing modules: ", e)
- }
- }
- }
-} catch (error) {
- console.error("Failed to load config.json:", error);
- process.exit(1);
-}
-
-// Export the Vite config
-export default defineConfig({
- plugins: [
- react(),
- tailwindcss()
- ],
- base: "/",
- server: {
- port: port,
- open: false,
- },
- preview: {
- port: port,
- open: false,
- },
-});
diff --git a/front/vite.config.ts b/front/vite.config.ts
new file mode 100644
index 00000000..0b83e6fc
--- /dev/null
+++ b/front/vite.config.ts
@@ -0,0 +1,43 @@
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+import tailwindcss from '@tailwindcss/vite';
+import fs from "fs";
+import path from "path";
+
+const CONFIG_LOCATION = process.env.CONFIG_LOCATION || "../secrets/front.ts";
+
+let config;
+
+const viteConfigDir = new URL('.', import.meta.url).pathname;
+const resolvedPath = path.resolve(viteConfigDir, CONFIG_LOCATION);
+
+if(CONFIG_LOCATION.endsWith(".ts") || CONFIG_LOCATION.endsWith(".js")) {
+ console.log(`Loading config from ${CONFIG_LOCATION} as a module...`);
+ // Load config from .ts or .js file using dynamic import
+ config = (await import(resolvedPath)).default;
+} else {
+ console.warn(`CONFIG_LOCATION ${CONFIG_LOCATION} does not end with .ts or .js. Defaulting to JSON parsing.`);
+ // Load config as JSON by default
+ config = JSON.parse(fs.readFileSync(resolvedPath, "utf8"));
+}
+
+const port = typeof config.port === "number" && config.port > 0 ? config.port : 8080;
+
+export default defineConfig({
+ plugins: [
+ react(),
+ tailwindcss()
+ ],
+ define: {
+ __GLOBAL_CONFIG__: JSON.stringify(config),
+ },
+ base: "/",
+ server: {
+ port: port,
+ open: false,
+ },
+ preview: {
+ port: port,
+ open: false,
+ },
+});
diff --git a/secrets/back.json.sample b/secrets/back.json.sample
index fb3efffa..eefdf292 100644
--- a/secrets/back.json.sample
+++ b/secrets/back.json.sample
@@ -1,6 +1,7 @@
{
"port": 8081,
"apiEndpoint": "https://chat-ai.academiccloud.de/v1",
+ "gatewayEndpoint": "https://chat-ai.academiccloud.de/v1",
"apiKey": "abcdefghijklmnopqrstuvw0123456789",
"serviceName": "Custom Chat AI"
}
\ No newline at end of file
diff --git a/secrets/front.config.ts b/secrets/front.config.ts
new file mode 100644
index 00000000..002ff241
--- /dev/null
+++ b/secrets/front.config.ts
@@ -0,0 +1,71 @@
+export type FrontMode = "prod" | "dev" | string;
+
+export interface FrontModulesConfig {
+ tools: boolean;
+ feedback: boolean;
+ choices: boolean;
+}
+
+export interface FrontDefaultModel {
+ id: string;
+ name?: string;
+}
+
+export interface FrontDefaultMessage {
+ role: "system" | "user" | "assistant" | "info";
+ content: string;
+}
+
+export interface FrontDefaultToolsConfig {
+ web_search: boolean;
+ image_generation: boolean;
+ image_modification: boolean;
+ audio_generation: boolean;
+ video_generation: boolean;
+ arcana: boolean;
+ mcp: boolean;
+}
+
+export interface FrontDefaultSettings {
+ model: FrontDefaultModel;
+ messages: FrontDefaultMessage[];
+ top_p: number;
+ temperature: number;
+ enable_tools: boolean;
+ tools: FrontDefaultToolsConfig;
+ arcana?: {id: string};
+}
+
+export interface FrontOverrides {
+ ui?: {
+ hideFooter?: boolean;
+ hideImportConversationButton?: boolean;
+ hideImportPersonaButton?: boolean;
+ hideSettings?: boolean;
+ hideModelSelector?: boolean;
+ showModelSelectorInChatArea?: boolean;
+ show_tour: boolean;
+ };
+ features?: {
+ };
+ models?: {
+ whitelist?: string[]; // Exclude all models except these from the UI (e.g. model selector)
+ blacklist?: string[]; // Hide these models from the UI (e.g. model selector)
+ };
+ branding?: "gwdg" | "mpg";
+}
+
+export interface FrontConfig {
+ mode: FrontMode;
+ port: number;
+ backendPath: string;
+ modelsPath: string;
+ userDataPath: string;
+ titleGenerationModel: string;
+ memoryGenerationModel: string;
+ proposalGenerationModel: string;
+ modules: FrontModulesConfig;
+ default: FrontDefaultSettings;
+ announcement: string;
+ overrides?: FrontOverrides;
+}
\ No newline at end of file
diff --git a/secrets/front.json.sample b/secrets/front.json.sample
index c2ea0b4e..767db7b1 100644
--- a/secrets/front.json.sample
+++ b/secrets/front.json.sample
@@ -27,5 +27,77 @@
"top_p": 0.5,
"temperature": 0.5
},
- "announcement": ""
+ "announcement": "",
+ "config": {
+ "ui": {
+ "showHeader": true,
+ "showAnnouncement": true,
+ "showSidebar": true,
+ "showSettingsPanel": true,
+ "showFooter": true,
+ "header": {
+ "showHamburgerMenu": true,
+ "showLogo": true,
+ "showModelSelector": true,
+ "showModelWarning": true,
+ "showSettingsButton": true
+ }
+ },
+ "features": {
+ "tools": { "enabled": false },
+ "feedback": { "enabled": false },
+ "choiceProposals": { "enabled": false },
+ "memory": { "enabled": true },
+ "arcana": { "enabled": true },
+ "mcp": { "enabled": true }
+ },
+ "models": {
+ "whitelist": null,
+ "blacklist": [],
+ "defaultModelId": "qwen3-30b-a3b-instruct-2507"
+ },
+ "defaults": {
+ "conversation": {
+ "model": {
+ "id": "qwen3-30b-a3b-instruct-2507",
+ "name": "Meta Llama 3.1 8B Instruct"
+ },
+ "messages": [
+ {"role": "system", "content": "You are a helpful assistant"}
+ ],
+ "top_p": 0.5,
+ "temperature": 0.5,
+ "memory": 0,
+ "enable_tools": false,
+ "tools": {
+ "web_search": false,
+ "image_generation": true,
+ "image_modification": true,
+ "audio_generation": true,
+ "video_generation": false,
+ "arcana": true,
+ "mcp": false
+ },
+ "enable_web_search": false,
+ "arcana": { "id": "" }
+ }
+ },
+ "branding": {
+ "appHomeUrl": "https://chat-ai.academiccloud.de/",
+ "appTitle": "Chat AI",
+ "logoUrl": "",
+ "logoSmallUrl": "",
+ "partnerLogoUrl": "",
+ "partnerLogoLink": "",
+ "announcement": ""
+ },
+ "api": {
+ "backendEndpoint": "http://localhost:8081",
+ "modelsEndpoint": "http://localhost:8081/models",
+ "userDataEndpoint": "http://localhost:8081/user",
+ "proposalGenerationModel": "qwen3-30b-a3b-instruct-2507",
+ "titleGenerationModel": "qwen3-30b-a3b-instruct-2507",
+ "memoryGenerationModel": "qwen3-30b-a3b-instruct-2507"
+ }
+ }
}
diff --git a/secrets/front.ts b/secrets/front.ts
new file mode 100644
index 00000000..35648687
--- /dev/null
+++ b/secrets/front.ts
@@ -0,0 +1,58 @@
+import type { FrontConfig } from "./front.config";
+
+const config: FrontConfig = {
+ "mode": "prod",
+ "port": 7220,
+ "backendPath": "/api",
+ "modelsPath": "/models",
+ "userDataPath": "/user",
+ "titleGenerationModel": "meta-llama-3.1-8b-instruct",
+ "memoryGenerationModel": "meta-llama-3.1-8b-instruct",
+ "proposalGenerationModel": "qwen3-30b-a3b-instruct-2507",
+ "modules": {
+ "tools": true,
+ "feedback": false,
+ "choices": true
+ },
+ "default": {
+ "model": {
+ "id": "qwen3-30b-a3b-instruct-2507",
+ "name": "Qwen 3 30B A3B Instruct 2507"
+ },
+ "messages": [
+ { "role": "system", "content": "You are a helpful assistant" }
+ ],
+ "top_p": 0.05,
+ "temperature": 0.0,
+ "enable_tools": true,
+ "tools": {
+ "web_search": false,
+ "image_generation": false,
+ "image_modification": false,
+ "audio_generation": false,
+ "video_generation": false,
+ "arcana": true,
+ "mcp": false
+ },
+ "arcana": undefined,
+ },
+ "announcement": "",
+ "overrides": {
+ "ui": {
+ "show_tour": false,
+ "hideFooter": true,
+ "hideImportConversationButton": true,
+ "hideImportPersonaButton": true,
+ "hideSettings": true,
+ "hideModelSelector": true,
+ "showModelSelectorInChatArea": true,
+ },
+ "features": {
+ },
+ "models": {
+ "whitelist": ["qwen3-30b-a3b-thinking-2507", "qwen3-30b-a3b-instruct-2507", "qwen3-omni-30b-a3b-instruct"]
+ },
+ "branding": "mpg",
+ },
+}
+export default config;
\ No newline at end of file