- Updated .env.example to include new configuration options for the UltiAI branding and API endpoints. - Enhanced Nginx configuration to support new API routes for the MCP and WebSocket connections. - Introduced sub-filters for branding adjustments in Nginx responses. - Added new JavaScript patch for API endpoint adjustments. - Implemented tests for new API functionalities and improved error handling in the AI gateway.
324 lines
10 KiB
JavaScript
324 lines
10 KiB
JavaScript
/**
|
|
* UltiAI embed bootstrap — must run before OpenWebUI SvelteKit init.
|
|
* Layout requires localStorage.token; without embed signin it redirects to /auth → redirect loop → 500.
|
|
*/
|
|
;(function () {
|
|
const STORAGE_KEY = "ulti.ai.session"
|
|
const CONTEXT_KEY = "ulti.ai.context"
|
|
const DEFAULT_MODEL_KEY = "ulti.ai.default_model"
|
|
const LAST_CHAT_PATH_KEY = "ulti.ai.last_chat_path"
|
|
const SIGNIN_URL = "/ai/api/v1/auths/signin"
|
|
const CONFIG_URL = "/api/v1/ai/config"
|
|
const BASE_SYSTEM_PROMPT = [
|
|
"Tu es UltiAI, l'assistant intégré à la suite Ultimail (mail, drive, contacts, agenda).",
|
|
"Réponds en français sauf demande contraire. Utilise les tools disponibles pour agir sur les données utilisateur.",
|
|
"Après chaque appel d'outil, réponds toujours en langage naturel : résume le résultat, cite les sources, propose la suite.",
|
|
"Ne termine jamais un tour utilisateur avec uniquement un appel d'outil sans texte explicatif.",
|
|
"Respecte strictement le paramètre limit des tools.",
|
|
].join(" ")
|
|
|
|
function readPageUrl() {
|
|
try {
|
|
return new URL(window.location.href)
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function isTemporaryEmbed() {
|
|
const url = readPageUrl()
|
|
return url?.searchParams.get("temporary-chat") === "true"
|
|
}
|
|
|
|
function modelFromUrl() {
|
|
const url = readPageUrl()
|
|
if (!url) return ""
|
|
const raw = url.searchParams.get("model") || url.searchParams.get("models") || ""
|
|
return raw.split(",")[0]?.trim() || ""
|
|
}
|
|
|
|
function applySelectedModels(modelIds) {
|
|
const ids = modelIds.map((id) => String(id || "").trim()).filter(Boolean)
|
|
if (!ids.length) return
|
|
try {
|
|
sessionStorage.setItem("selectedModels", JSON.stringify(ids))
|
|
localStorage.setItem(DEFAULT_MODEL_KEY, ids[0])
|
|
} catch {
|
|
// storage unavailable
|
|
}
|
|
}
|
|
|
|
function bootstrapSelectedModels(modelHint) {
|
|
const resolved = modelFromUrl() || String(modelHint || "").trim() || localStorage.getItem(DEFAULT_MODEL_KEY) || ""
|
|
if (resolved) applySelectedModels([resolved])
|
|
return resolved
|
|
}
|
|
|
|
function restoreLastChatIfHome() {
|
|
if (isTemporaryEmbed()) return
|
|
const url = readPageUrl()
|
|
if (!url) return
|
|
const path = url.pathname.replace(/\/$/, "") || "/"
|
|
if (path !== "/ai") return
|
|
if (url.searchParams.get("model") || url.searchParams.get("models")) return
|
|
try {
|
|
const last = localStorage.getItem(LAST_CHAT_PATH_KEY)
|
|
if (!last || !/\/c\/[^/]+/.test(last)) return
|
|
const target = last.startsWith("/") ? last : `/ai${last}`
|
|
if (target === url.pathname + url.search) return
|
|
window.location.replace(target)
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
function watchChatRoute() {
|
|
const save = () => {
|
|
if (isTemporaryEmbed()) return
|
|
try {
|
|
const path = window.location.pathname
|
|
if (/\/c\/[^/]+/.test(path)) {
|
|
localStorage.setItem(LAST_CHAT_PATH_KEY, path + window.location.search)
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
window.addEventListener("popstate", save)
|
|
const timer = window.setInterval(save, 2000)
|
|
window.addEventListener("beforeunload", () => window.clearInterval(timer))
|
|
save()
|
|
}
|
|
|
|
function ensureEmbedSignin() {
|
|
try {
|
|
if (localStorage.getItem("token")) return
|
|
const xhr = new XMLHttpRequest()
|
|
xhr.open("POST", SIGNIN_URL, false)
|
|
xhr.withCredentials = true
|
|
xhr.setRequestHeader("Content-Type", "application/json")
|
|
xhr.send(JSON.stringify({ email: "", password: "" }))
|
|
if (xhr.status !== 200) return
|
|
const data = JSON.parse(xhr.responseText)
|
|
if (data && data.token) {
|
|
localStorage.setItem("token", data.token)
|
|
}
|
|
} catch {
|
|
// Ultimail session not ready yet — parent may post ULTI_SESSION later
|
|
}
|
|
}
|
|
|
|
async function fetchEmbedConfig() {
|
|
try {
|
|
const response = await fetch(CONFIG_URL, { credentials: "include" })
|
|
if (!response.ok) return null
|
|
return await response.json()
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
async function syncUserModelPreference(modelId) {
|
|
const model = String(modelId || "").trim()
|
|
if (!model) return false
|
|
const token = localStorage.getItem("token")
|
|
if (!token) return false
|
|
try {
|
|
const settingsRes = await fetch("/ai/api/v1/users/user/settings", {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
credentials: "include",
|
|
})
|
|
if (!settingsRes.ok) return false
|
|
const settings = await settingsRes.json()
|
|
const ui = settings?.ui || {}
|
|
const current = ui.models || ui.model_ids
|
|
if (Array.isArray(current) && current.length > 0 && String(current[0] || "").trim()) {
|
|
return true
|
|
}
|
|
|
|
const modelsRes = await fetch("/ai/api/models", {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
credentials: "include",
|
|
})
|
|
if (modelsRes.ok) {
|
|
const modelsPayload = await modelsRes.json()
|
|
const items = Array.isArray(modelsPayload?.data)
|
|
? modelsPayload.data
|
|
: Array.isArray(modelsPayload)
|
|
? modelsPayload
|
|
: []
|
|
const known = items.some((entry) => entry && entry.id === model)
|
|
if (!known) return false
|
|
}
|
|
|
|
const updateRes = await fetch("/ai/api/v1/users/user/settings/update", {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
credentials: "include",
|
|
body: JSON.stringify({ ui: { models: [model], model_ids: [model] } }),
|
|
})
|
|
if (!updateRes.ok) return false
|
|
applySelectedModels([model])
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
function scheduleModelSync(modelId) {
|
|
const model = String(modelId || "").trim()
|
|
if (!model) return
|
|
let attempts = 0
|
|
const tick = async () => {
|
|
attempts += 1
|
|
const done = await syncUserModelPreference(model)
|
|
if (done || attempts >= 12) return
|
|
window.setTimeout(tick, 1000)
|
|
}
|
|
window.setTimeout(tick, 500)
|
|
}
|
|
|
|
function readContextPrompt() {
|
|
try {
|
|
const raw = sessionStorage.getItem(CONTEXT_KEY)
|
|
if (!raw) return BASE_SYSTEM_PROMPT
|
|
const parsed = JSON.parse(raw)
|
|
const parts = [BASE_SYSTEM_PROMPT, parsed.systemPrompt].filter(Boolean)
|
|
return parts.join("\n\n")
|
|
} catch {
|
|
return BASE_SYSTEM_PROMPT
|
|
}
|
|
}
|
|
|
|
function injectSystemPrompt(body) {
|
|
if (!body || !Array.isArray(body.messages)) return body
|
|
const prompt = readContextPrompt()
|
|
if (!prompt) return body
|
|
const messages = body.messages.slice()
|
|
const systemIndex = messages.findIndex((m) => m && m.role === "system")
|
|
if (systemIndex >= 0) {
|
|
messages[systemIndex] = { ...messages[systemIndex], content: prompt }
|
|
} else {
|
|
messages.unshift({ role: "system", content: prompt })
|
|
}
|
|
return { ...body, messages }
|
|
}
|
|
|
|
function patchFetch() {
|
|
if (window.__ultiFetchPatched) return
|
|
window.__ultiFetchPatched = true
|
|
const originalFetch = window.fetch.bind(window)
|
|
window.fetch = async function patchedFetch(input, init) {
|
|
const url = typeof input === "string" ? input : input instanceof Request ? input.url : ""
|
|
const method = (init && init.method) || (input instanceof Request ? input.method : "GET")
|
|
if (
|
|
method.toUpperCase() === "POST" &&
|
|
/\/api\/(v1\/)?chat\/completions(?:\?|$)/.test(url) &&
|
|
init &&
|
|
typeof init.body === "string"
|
|
) {
|
|
try {
|
|
const body = JSON.parse(init.body)
|
|
const patched = injectSystemPrompt(body)
|
|
if (patched !== body) {
|
|
init = { ...init, body: JSON.stringify(patched) }
|
|
}
|
|
} catch {
|
|
// ignore malformed bodies
|
|
}
|
|
}
|
|
return originalFetch(input, init)
|
|
}
|
|
}
|
|
|
|
function brandDocument() {
|
|
const titleEl = document.querySelector("title")
|
|
if (titleEl) {
|
|
const t = titleEl.textContent || ""
|
|
if (t.includes("Open WebUI")) {
|
|
titleEl.textContent = t.replace(/UltiAI \(Open WebUI\)/g, "UltiAI").replace(/Open WebUI/g, "UltiAI")
|
|
} else if (!t.trim()) {
|
|
titleEl.textContent = "UltiAI"
|
|
}
|
|
}
|
|
}
|
|
|
|
function watchDocumentTitle() {
|
|
brandDocument()
|
|
const titleEl = document.querySelector("title")
|
|
if (!titleEl) return
|
|
new MutationObserver(() => brandDocument()).observe(titleEl, {
|
|
childList: true,
|
|
characterData: true,
|
|
subtree: true,
|
|
})
|
|
}
|
|
|
|
restoreLastChatIfHome()
|
|
bootstrapSelectedModels("")
|
|
ensureEmbedSignin()
|
|
patchFetch()
|
|
watchDocumentTitle()
|
|
watchChatRoute()
|
|
|
|
void (async () => {
|
|
const config = await fetchEmbedConfig()
|
|
const model = bootstrapSelectedModels(config?.default_model)
|
|
if (!localStorage.getItem("token")) ensureEmbedSignin()
|
|
if (model) scheduleModelSync(model)
|
|
})()
|
|
|
|
window.addEventListener("message", (event) => {
|
|
if (event.source !== window.parent) return
|
|
const data = event.data
|
|
if (!data || typeof data !== "object") return
|
|
|
|
if (data.type === "ULTI_SESSION") {
|
|
try {
|
|
sessionStorage.setItem(
|
|
STORAGE_KEY,
|
|
JSON.stringify({
|
|
token_secret: data.token_secret,
|
|
session_id: data.session_id,
|
|
mcp_url: data.mcp_url,
|
|
enabled_tools: data.enabled_tools,
|
|
default_model: data.default_model,
|
|
updated_at: Date.now(),
|
|
})
|
|
)
|
|
} catch {
|
|
// sessionStorage unavailable
|
|
}
|
|
if (data.default_model && typeof data.default_model === "string") {
|
|
const model = data.default_model.trim()
|
|
if (model) {
|
|
applySelectedModels([model])
|
|
scheduleModelSync(model)
|
|
}
|
|
}
|
|
if (!localStorage.getItem("token")) {
|
|
ensureEmbedSignin()
|
|
}
|
|
return
|
|
}
|
|
|
|
if (data.type === "ULTI_CONTEXT_UPDATE") {
|
|
try {
|
|
sessionStorage.setItem(
|
|
CONTEXT_KEY,
|
|
JSON.stringify({
|
|
systemPrompt: data.systemPrompt,
|
|
context: data.context,
|
|
updated_at: Date.now(),
|
|
})
|
|
)
|
|
} catch {
|
|
// sessionStorage unavailable
|
|
}
|
|
}
|
|
})
|
|
})()
|