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 ? ( + + ) : ( +
+ {/* Render Mode Selector on the bottom left*/} +
+ {renderModes.map((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) */} +
+
+ {titleText} +
- {reference?.url && ( - e.stopPropagation()} - title="Open source" - > - - - )} + {reference?.arcanaName && ( + + {reference.arcanaName} + + )} + + {reference?.url && ( + e.stopPropagation()} + title="Open source" + > + + + )} - {isPartial && isStreaming && ( - -
- - loading... + {isPartial && isStreaming && ( + +
+ + loading... +
-
+ )} +
+ + {/* 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 */} + + + {/* Dropdown Panel */} + {dropdownOpen && ( +
+ {/* Header Section */} +
+
+ + + {t("model_selector.docs_hint_text")}{" "} + + {t("model_selector.docs_hint_link")} + + +
+
+ + {/* 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 }) => ( + + )} + + ))} +
+
+
+
+
+ + {/* Model List */} +
+ {filteredModelsList.length === 0 ? ( +
+ No models found +
+ ) : ( + filteredModelsList.map((model) => ( + + )) + )} +
+ + {/* 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 */} - - - -
+ {/* Import persona from Github */} + {!config.overrides?.ui?.hideImportPersonaButton && ( + + + + )} +
+ )} ); } 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 */}
-
+ {!(config.overrides?.ui?.hideImportConversationButton && config.overrides?.ui?.hideImportPersonaButton) && ( +
+ {/* Import Conversation button */} + {!config.overrides?.ui?.hideImportConversationButton && ( + + )} + {/* Import Persona button */} + {!config.overrides?.ui?.hideImportPersonaButton && ( + + )} +
+ )} {/* 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", + }} + > + + + + + + + {( )} -
+
-
-
- - setSearchQuery(e.target.value)} - onKeyDown={(e) => { - if (e.key !== "Escape") return; - e.preventDefault(); - if (hasSearch) { - setSearchQuery(""); - } else { - setSearchOpen(false); - } - }} - placeholder={t("folders.search_placeholder")} - className="w-full bg-transparent pl-9 pr-9 py-2 text-xs text-black dark:text-white placeholder:text-gray-500 dark:placeholder:text-gray-400 focus:outline-none" - /> - {searchQuery.length > 0 ? ( - - ) : ( - - )} -
+ > +
+ + setSearchQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key !== "Escape") return; + e.preventDefault(); + if (hasSearch) { + setSearchQuery(""); + } else { + setSearchOpen(false); + } + }} + placeholder={t("folders.search_placeholder")} + className="w-full bg-transparent pl-9 pr-9 py-2 text-xs text-black dark:text-white placeholder:text-gray-500 dark:placeholder:text-gray-400 focus:outline-none" + /> + {searchQuery.length > 0 ? ( + + ) : ( + + )}
+
{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 */}
{/* Bottom section */} -
-
- - + {!(config.overrides?.ui?.hideImportConversationButton && config.overrides?.ui?.hideImportPersonaButton) && ( +
+
+ {!config.overrides?.ui?.hideImportConversationButton && } + {!config.overrides?.ui?.hideImportPersonaButton && ( + + )} +
-
+ )}
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 */} - Chat AI Logo - - {/* Chevron Button */} - - - -
+ {config.overrides?.branding !== "mpg" && ( +
+ {/* Logo */} + Chat AI Logo + + {/* Chevron Button */} + + + +
+ )} + {config.overrides?.branding === "mpg" && ( +
+ {/* Chevron Button visible by default for mpg branding */} + + + +
+ )} {/** Actions */}
@@ -141,23 +158,27 @@ export default function SidebarRail({ localState, onOpen, handleNewConversation
-
- {/* Import Conversation button */} - - - {/* Import persona from Github */} - - - -
+ {!(config.overrides?.ui?.hideImportConversationButton && config.overrides?.ui?.hideImportPersonaButton) && ( +
+ {/* Import Conversation button */} + {!config.overrides?.ui?.hideImportConversationButton && } + + {/* Import persona from Github */} + {!config.overrides?.ui?.hideImportPersonaButton && ( + + + + )} +
+ )}
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