* feat: improve ollama model management experience
This commit introduces several improvements to the Ollama model management modal:
- Adds a cancel button to the model pulling operation, using the existing 'x' button pattern.
- Adds a cancel button to the "Update All" models operation, allowing the user to cancel the update for the currently processing model.
- Cleans up toast notifications when updating all models. A single toast is now shown at the beginning and a summary toast at the end, preventing notification spam.
- Refactors the `ManageOllama.svelte` component to support these new cancellation features.
- Adds tooltips to all buttons in the modal to improve clarity.
- Disables buttons when their corresponding input fields are empty to prevent accidental clicks.
* fix
* i18n: improve Chinese translation
* fix: handle non‑UTF8 chars in third‑party responses without error
* German translation of new strings in i18n
* log web search queries only with level 'debug' instead of 'info'
* Tool calls now only include text and dont inlcude other content like image b64
* fix onedrive
* fix: discovery url
* fix: default permissions not being loaded
* fix: ai hallucination
* fix: non rich text input copy
* refac: rm print statements
* refac: disable direct models from model editors
* refac/fix: do not process xlsx files with azure doc intelligence
* Update pull_request_template.md
* Update generated image translation in DE-de
* added missing danish translations
* feat(onedrive): Enable search and "My Organization" pivot
* style(onedrive): Formatting fix
* feat: Implement toggling for vertical and horizontal flow layouts
This commit introduces the necessary logic and UI controls to allow users to switch the Flow component layout between vertical and horizontal orientations.
* **`Flow.svelte` Refactoring:**
* Updates logic for calculating level offsets and node positions to consistently respect the current flow orientation.
* Adds a control panel using `<Controls>` and `<SwitchButton>` components.
* Provides user interface elements to easily switch the flow layout between horizontal and vertical orientations.
* build(deps): bump pydantic from 2.11.7 to 2.11.9 in /backend
Bumps [pydantic](https://github.com/pydantic/pydantic) from 2.11.7 to 2.11.9.
- [Release notes](https://github.com/pydantic/pydantic/releases)
- [Changelog](https://github.com/pydantic/pydantic/blob/v2.11.9/HISTORY.md)
- [Commits](https://github.com/pydantic/pydantic/compare/v2.11.7...v2.11.9)
---
updated-dependencies:
- dependency-name: pydantic
dependency-version: 2.11.9
dependency-type: direct:production
update-type: version-update:semver-patch
...
Signed-off-by: dependabot[bot] <support@github.com>
* build(deps): bump black from 25.1.0 to 25.9.0 in /backend
Bumps [black](https://github.com/psf/black) from 25.1.0 to 25.9.0.
- [Release notes](https://github.com/psf/black/releases)
- [Changelog](https://github.com/psf/black/blob/main/CHANGES.md)
- [Commits](https://github.com/psf/black/compare/25.1.0...25.9.0)
---
updated-dependencies:
- dependency-name: black
dependency-version: 25.9.0
dependency-type: direct:production
update-type: version-update:semver-minor
...
Signed-off-by: dependabot[bot] <support@github.com>
* build(deps): bump markdown from 3.8.2 to 3.9 in /backend
Bumps [markdown](https://github.com/Python-Markdown/markdown) from 3.8.2 to 3.9.
- [Release notes](https://github.com/Python-Markdown/markdown/releases)
- [Changelog](https://github.com/Python-Markdown/markdown/blob/master/docs/changelog.md)
- [Commits](https://github.com/Python-Markdown/markdown/compare/3.8.2...3.9.0)
---
updated-dependencies:
- dependency-name: markdown
dependency-version: '3.9'
dependency-type: direct:production
update-type: version-update:semver-minor
...
Signed-off-by: dependabot[bot] <support@github.com>
* build(deps): bump chromadb from 1.0.20 to 1.1.0 in /backend
Bumps [chromadb](https://github.com/chroma-core/chroma) from 1.0.20 to 1.1.0.
- [Release notes](https://github.com/chroma-core/chroma/releases)
- [Changelog](https://github.com/chroma-core/chroma/blob/main/RELEASE_PROCESS.md)
- [Commits](https://github.com/chroma-core/chroma/compare/1.0.20...1.1.0)
---
updated-dependencies:
- dependency-name: chromadb
dependency-version: 1.1.0
dependency-type: direct:production
update-type: version-update:semver-minor
...
Signed-off-by: dependabot[bot] <support@github.com>
* build(deps): bump opentelemetry-api from 1.36.0 to 1.37.0
Bumps [opentelemetry-api](https://github.com/open-telemetry/opentelemetry-python) from 1.36.0 to 1.37.0.
- [Release notes](https://github.com/open-telemetry/opentelemetry-python/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-python/blob/main/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-python/compare/v1.36.0...v1.37.0)
---
updated-dependencies:
- dependency-name: opentelemetry-api
dependency-version: 1.37.0
dependency-type: direct:production
update-type: version-update:semver-minor
...
Signed-off-by: dependabot[bot] <support@github.com>
* refac: ollama embed form data
* fix: non rich text handling
* fix: oauth client registration
* refac
* chore: dep bump
* chore: fastapi bump
* chore/refac: bump bcrypt and remove passlib
* Improving Korean Translation
* refac
* Improving Korean Translation
* feat: PWA share_target implementation
Co-Authored-By: gjveld <19951982+gjveld@users.noreply.github.com>
* refac: message input mobile detection behaviour
* feat: model_ids per folder
* Update translation.json (pt-BR)
inclusion of new translations of items that have been added
* refac
* refac
* refac
* refac
* refac/fix: temp chat
* refac
* refac: stop task
* refac/fix: azure audio escape
* refac: external tool validation
* refac/enh: start.sh additional args support
* refac
* refac: styling
* refac/fix: direct connection floating action buttons
* refac/fix: system prompt duplication
* refac/enh: openai tts additional params support
* refac
* feat: load data in parallel to accelerate page loading speed
* i18n: improve Chinese translation
* refac
* refac: model selector
* UPD: i18n es-ES Translation v0.6.33
UPD: i18n es-ES Translation v0.6.33
Updated new strings.
* refac
* improved query pref by querying only relevant columns
* refac/enh: docling params
* refac
* refac: openai additional headers support
* refac
* FEAT: Add Vega Char Visualizer Renderer
### FEAT: Add Vega Char Visualizer Renderer
Feature required in https://github.com/open-webui/open-webui/discussions/18022
Added npm vega lib to package.json
Added function for visualization renderer to src/libs/utils/index.ts
Added logic to src/lib/components/chat/Messages/CodeBlock.svelte
The treatment is similar as for mermaid diagrams.
Reference: https://vega.github.io/vega/
* refac
* chore
* refac
* FEAT: Add Vega-Lite Char Visualizer Renderer
### FEAT: Add Vega Char Visualizer Renderer
Add suport for Vega-Lite Specifications.
Vega-Lite is a "compiled" version of Vega Char Visualizer.
For be rendered with Vega it have to be compiled.
This PR add the check and compile if necessary, is a complement of recent Vega Renderer Feature added.
* refac
* refac/fix: switch
* enh/refac: url input handling
* refac
* refac: styling
* UPD: Add Validators & Error Toast for Mermaid & Vega diagrams
### UPD: Feat: Add Validators & Error Toast for Mermaid & Vega diagrams
Description:
As many time the diagrams generated or entered have syntax errors the diagrams are not rendered due to that errors, but as there isn't any notification is difficult to know what happend.
This PR add validator and toast notification when error on Mermaid and Vega/Vega-Lite diagrams, helping the user to fix its.
* removed redundant knowledge API call
* Fix Code Format
* refac: model workspace view
* refac
* refac: knowledge
* refac: prompts
* refac: tools
* refac
* feat: attach folder
* refac: make tencentcloud-sdk-python optional
* refac/fix: oauth
* enh: ENABLE_OAUTH_EMAIL_FALLBACK
* refac/fix: folders
* Update requirements.txt
* Update pyproject.toml
* UPD: Add Validators & Error Toast for Mermaid & Vega diagrams
### UPD: Feat: Add Validators & Error Toast for Mermaid & Vega diagrams
Description:
As many time the diagrams generated or entered have syntax errors the diagrams are not rendered due to that errors, but as there isn't any notification is difficult to know what happend.
This PR add validator and toast notification when error on Mermaid and Vega/Vega-Lite diagrams, helping the user to fix its.
Note:
Another possibility of integrating this Graph Visualizer is through its svelte component: https://github.com/vega/svelte-vega/tree/main/packages/svelte-vega
* Removed unused toast import & Code Format
* refac
* refac: external tool server view
* refac
* refac: overview
* refac: styling
* refac
* Update bug_report.yaml
* refac
* refac
* refac
* refac
* refac: oauth client fallback
* Fixed: Cannot handle batch sizes > 1 if no padding token is defined
Fixes Cannot handle batch sizes > 1 if no padding token is defined
For reranker models that do not have this defined in their config by using the eos_token_id if present as pad_token_id.
* refac: fallback to reasoning content
* fix(i18n): corrected typo in Spanish translation for "Reasoning Tags"
Typo fixed in Spanish translation file at line 1240 of `open-webui/src/lib/i18n/locales/es-ES/translation.json`:
- Incorrect: "Eriquetas de Razonamiento"
- Correct: "Etiquetas de Razonamiento"
This improves clarity and consistency in the UI.
* refac/fix: ENABLE_STAR_SESSIONS_MIDDLEWARE
* refac/fix: redirect
* refac
* refac
* refac
* refac: web search error handling
* refac: source parsing
* refac: functions
* refac
* refac/enh: note pdf export
* refac/fix: mcp oauth2.1
* chore: format
* chore: Changelog (#17995)
* Update CHANGELOG.md
* Update CHANGELOG.md
* Update CHANGELOG.md
* Update CHANGELOG.md
* Update CHANGELOG.md
* Update CHANGELOG.md
* Update CHANGELOG.md
* Update CHANGELOG.md
* Update CHANGELOG.md
* Update CHANGELOG.md
* Update CHANGELOG.md
* Update CHANGELOG.md
* Update CHANGELOG.md
* Update CHANGELOG.md
* Update CHANGELOG.md
* Update CHANGELOG.md
* Update CHANGELOG.md
* Update CHANGELOG.md
* Update CHANGELOG.md
* Update CHANGELOG.md
* Update CHANGELOG.md
* Update CHANGELOG.md
* Update CHANGELOG.md
* Update CHANGELOG.md
* Update CHANGELOG.md
* Update CHANGELOG.md
* Update CHANGELOG.md
* refac
* chore: dep bump
---------
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: silentoplayz <jacwoo21@outlook.com>
Co-authored-by: Shirasawa <764798966@qq.com>
Co-authored-by: Jan Kessler <jakessle@uni-mainz.de>
Co-authored-by: Jacob Leksan <jacob.leksan@expedient.com>
Co-authored-by: Classic298 <27028174+Classic298@users.noreply.github.com>
Co-authored-by: sinejespersen <sinejespersen@protonmail.com>
Co-authored-by: Selene Blok <selene.blok@rws.nl>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Cyp <cypher9715@naver.com>
Co-authored-by: gjveld <19951982+gjveld@users.noreply.github.com>
Co-authored-by: joaoback <156559121+joaoback@users.noreply.github.com>
Co-authored-by: _00_ <131402327+rgaricano@users.noreply.github.com>
Co-authored-by: expruc <eygabi01@gmail.com>
Co-authored-by: YetheSamartaka <55753928+YetheSamartaka@users.noreply.github.com>
Co-authored-by: Akutangulo <akutangulo@gmail.com>
2573 lines
66 KiB
Svelte
2573 lines
66 KiB
Svelte
<script lang="ts">
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
import { toast } from 'svelte-sonner';
|
|
import { PaneGroup, Pane, PaneResizer } from 'paneforge';
|
|
|
|
import { getContext, onDestroy, onMount, tick } from 'svelte';
|
|
const i18n: Writable<i18nType> = getContext('i18n');
|
|
|
|
import { goto } from '$app/navigation';
|
|
import { page } from '$app/stores';
|
|
|
|
import { get, type Unsubscriber, type Writable } from 'svelte/store';
|
|
import type { i18n as i18nType } from 'i18next';
|
|
import { WEBUI_BASE_URL } from '$lib/constants';
|
|
|
|
import {
|
|
chatId,
|
|
chats,
|
|
config,
|
|
type Model,
|
|
models,
|
|
tags as allTags,
|
|
settings,
|
|
showSidebar,
|
|
WEBUI_NAME,
|
|
banners,
|
|
user,
|
|
socket,
|
|
showControls,
|
|
showCallOverlay,
|
|
currentChatPage,
|
|
temporaryChatEnabled,
|
|
mobile,
|
|
showOverview,
|
|
chatTitle,
|
|
showArtifacts,
|
|
tools,
|
|
toolServers,
|
|
functions,
|
|
selectedFolder,
|
|
pinnedChats,
|
|
showEmbeds
|
|
} from '$lib/stores';
|
|
import {
|
|
convertMessagesToHistory,
|
|
copyToClipboard,
|
|
getMessageContentParts,
|
|
createMessagesList,
|
|
getPromptVariables,
|
|
processDetails,
|
|
removeAllDetails
|
|
} from '$lib/utils';
|
|
|
|
import {
|
|
createNewChat,
|
|
getAllTags,
|
|
getChatById,
|
|
getChatList,
|
|
getPinnedChatList,
|
|
getTagsById,
|
|
updateChatById,
|
|
updateChatFolderIdById
|
|
} from '$lib/apis/chats';
|
|
import { generateOpenAIChatCompletion } from '$lib/apis/openai';
|
|
import { processWeb, processWebSearch, processYoutubeVideo } from '$lib/apis/retrieval';
|
|
import { getAndUpdateUserLocation, getUserSettings } from '$lib/apis/users';
|
|
import {
|
|
chatCompleted,
|
|
generateQueries,
|
|
chatAction,
|
|
generateMoACompletion,
|
|
stopTask,
|
|
getTaskIdsByChatId
|
|
} from '$lib/apis';
|
|
import { getTools } from '$lib/apis/tools';
|
|
import { uploadFile } from '$lib/apis/files';
|
|
import { createOpenAITextStream } from '$lib/apis/streaming';
|
|
|
|
import { fade } from 'svelte/transition';
|
|
|
|
import Banner from '../common/Banner.svelte';
|
|
import MessageInput from '$lib/components/chat/MessageInput.svelte';
|
|
import Messages from '$lib/components/chat/Messages.svelte';
|
|
import Navbar from '$lib/components/chat/Navbar.svelte';
|
|
import ChatControls from './ChatControls.svelte';
|
|
import EventConfirmDialog from '../common/ConfirmDialog.svelte';
|
|
import Placeholder from './Placeholder.svelte';
|
|
import NotificationToast from '../NotificationToast.svelte';
|
|
import Spinner from '../common/Spinner.svelte';
|
|
import Tooltip from '../common/Tooltip.svelte';
|
|
import Sidebar from '../icons/Sidebar.svelte';
|
|
import { getFunctions } from '$lib/apis/functions';
|
|
import Image from '../common/Image.svelte';
|
|
import { updateFolderById } from '$lib/apis/folders';
|
|
|
|
export let chatIdProp = '';
|
|
|
|
let loading = true;
|
|
|
|
const eventTarget = new EventTarget();
|
|
let controlPane;
|
|
let controlPaneComponent;
|
|
|
|
let messageInput;
|
|
|
|
let autoScroll = true;
|
|
let processing = '';
|
|
let messagesContainerElement: HTMLDivElement;
|
|
|
|
let navbarElement;
|
|
|
|
let showEventConfirmation = false;
|
|
let eventConfirmationTitle = '';
|
|
let eventConfirmationMessage = '';
|
|
let eventConfirmationInput = false;
|
|
let eventConfirmationInputPlaceholder = '';
|
|
let eventConfirmationInputValue = '';
|
|
let eventCallback = null;
|
|
|
|
let chatIdUnsubscriber: Unsubscriber | undefined;
|
|
|
|
let selectedModels = [''];
|
|
let atSelectedModel: Model | undefined;
|
|
let selectedModelIds = [];
|
|
$: selectedModelIds = atSelectedModel !== undefined ? [atSelectedModel.id] : selectedModels;
|
|
|
|
let selectedToolIds = [];
|
|
let selectedFilterIds = [];
|
|
let imageGenerationEnabled = false;
|
|
let webSearchEnabled = false;
|
|
let codeInterpreterEnabled = false;
|
|
|
|
let showCommands = false;
|
|
|
|
let generating = false;
|
|
let generationController = null;
|
|
|
|
let chat = null;
|
|
let tags = [];
|
|
|
|
let history = {
|
|
messages: {},
|
|
currentId: null
|
|
};
|
|
|
|
let taskIds = null;
|
|
|
|
// Chat Input
|
|
let prompt = '';
|
|
let chatFiles = [];
|
|
let files = [];
|
|
let params = {};
|
|
|
|
$: if (chatIdProp) {
|
|
navigateHandler();
|
|
}
|
|
|
|
const navigateHandler = async () => {
|
|
loading = true;
|
|
|
|
prompt = '';
|
|
messageInput?.setText('');
|
|
|
|
files = [];
|
|
selectedToolIds = [];
|
|
selectedFilterIds = [];
|
|
webSearchEnabled = false;
|
|
imageGenerationEnabled = false;
|
|
|
|
const storageChatInput = sessionStorage.getItem(
|
|
`chat-input${chatIdProp ? `-${chatIdProp}` : ''}`
|
|
);
|
|
|
|
if (chatIdProp && (await loadChat())) {
|
|
await tick();
|
|
loading = false;
|
|
window.setTimeout(() => scrollToBottom(), 0);
|
|
|
|
await tick();
|
|
|
|
if (storageChatInput) {
|
|
try {
|
|
const input = JSON.parse(storageChatInput);
|
|
|
|
if (!$temporaryChatEnabled) {
|
|
messageInput?.setText(input.prompt);
|
|
files = input.files;
|
|
selectedToolIds = input.selectedToolIds;
|
|
selectedFilterIds = input.selectedFilterIds;
|
|
webSearchEnabled = input.webSearchEnabled;
|
|
imageGenerationEnabled = input.imageGenerationEnabled;
|
|
codeInterpreterEnabled = input.codeInterpreterEnabled;
|
|
}
|
|
} catch (e) {}
|
|
}
|
|
|
|
const chatInput = document.getElementById('chat-input');
|
|
chatInput?.focus();
|
|
} else {
|
|
await goto('/');
|
|
}
|
|
};
|
|
|
|
const onSelect = async (e) => {
|
|
const { type, data } = e;
|
|
|
|
if (type === 'prompt') {
|
|
// Handle prompt selection
|
|
messageInput?.setText(data, async () => {
|
|
if (!($settings?.insertSuggestionPrompt ?? false)) {
|
|
await tick();
|
|
submitPrompt(prompt);
|
|
}
|
|
});
|
|
}
|
|
};
|
|
|
|
$: if (selectedModels && chatIdProp !== '') {
|
|
saveSessionSelectedModels();
|
|
}
|
|
|
|
const saveSessionSelectedModels = () => {
|
|
const selectedModelsString = JSON.stringify(selectedModels);
|
|
if (
|
|
selectedModels.length === 0 ||
|
|
(selectedModels.length === 1 && selectedModels[0] === '') ||
|
|
sessionStorage.selectedModels === selectedModelsString
|
|
) {
|
|
return;
|
|
}
|
|
sessionStorage.selectedModels = selectedModelsString;
|
|
console.log('saveSessionSelectedModels', selectedModels, sessionStorage.selectedModels);
|
|
};
|
|
|
|
let oldSelectedModelIds = [''];
|
|
$: if (JSON.stringify(selectedModelIds) !== JSON.stringify(oldSelectedModelIds)) {
|
|
onSelectedModelIdsChange();
|
|
}
|
|
|
|
const onSelectedModelIdsChange = () => {
|
|
if (oldSelectedModelIds.filter((id) => id).length > 0) {
|
|
resetInput();
|
|
}
|
|
oldSelectedModelIds = selectedModelIds;
|
|
};
|
|
|
|
const resetInput = () => {
|
|
selectedToolIds = [];
|
|
selectedFilterIds = [];
|
|
webSearchEnabled = false;
|
|
imageGenerationEnabled = false;
|
|
codeInterpreterEnabled = false;
|
|
|
|
setDefaults();
|
|
};
|
|
|
|
const setDefaults = async () => {
|
|
if (!$tools) {
|
|
tools.set(await getTools(localStorage.token));
|
|
}
|
|
if (!$functions) {
|
|
functions.set(await getFunctions(localStorage.token));
|
|
}
|
|
if (selectedModels.length !== 1 && !atSelectedModel) {
|
|
return;
|
|
}
|
|
|
|
const model = atSelectedModel ?? $models.find((m) => m.id === selectedModels[0]);
|
|
if (model) {
|
|
// Set Default Tools
|
|
if (model?.info?.meta?.toolIds) {
|
|
selectedToolIds = [
|
|
...new Set(
|
|
[...(model?.info?.meta?.toolIds ?? [])].filter((id) => $tools.find((t) => t.id === id))
|
|
)
|
|
];
|
|
}
|
|
|
|
// Set Default Filters (Toggleable only)
|
|
if (model?.info?.meta?.defaultFilterIds) {
|
|
selectedFilterIds = model.info.meta.defaultFilterIds.filter((id) =>
|
|
model?.filters?.find((f) => f.id === id)
|
|
);
|
|
}
|
|
|
|
// Set Default Features
|
|
if (model?.info?.meta?.defaultFeatureIds) {
|
|
if (model.info?.meta?.capabilities?.['image_generation']) {
|
|
imageGenerationEnabled = model.info.meta.defaultFeatureIds.includes('image_generation');
|
|
}
|
|
|
|
if (model.info?.meta?.capabilities?.['web_search']) {
|
|
webSearchEnabled = model.info.meta.defaultFeatureIds.includes('web_search');
|
|
}
|
|
|
|
if (model.info?.meta?.capabilities?.['code_interpreter']) {
|
|
codeInterpreterEnabled = model.info.meta.defaultFeatureIds.includes('code_interpreter');
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const showMessage = async (message, ignoreSettings = false) => {
|
|
await tick();
|
|
|
|
const _chatId = JSON.parse(JSON.stringify($chatId));
|
|
let _messageId = JSON.parse(JSON.stringify(message.id));
|
|
|
|
let messageChildrenIds = [];
|
|
if (_messageId === null) {
|
|
messageChildrenIds = Object.keys(history.messages).filter(
|
|
(id) => history.messages[id].parentId === null
|
|
);
|
|
} else {
|
|
messageChildrenIds = history.messages[_messageId].childrenIds;
|
|
}
|
|
|
|
while (messageChildrenIds.length !== 0) {
|
|
_messageId = messageChildrenIds.at(-1);
|
|
messageChildrenIds = history.messages[_messageId].childrenIds;
|
|
}
|
|
|
|
history.currentId = _messageId;
|
|
|
|
await tick();
|
|
await tick();
|
|
await tick();
|
|
|
|
if (($settings?.scrollOnBranchChange ?? true) || ignoreSettings) {
|
|
const messageElement = document.getElementById(`message-${message.id}`);
|
|
if (messageElement) {
|
|
messageElement.scrollIntoView({ behavior: 'smooth' });
|
|
}
|
|
}
|
|
|
|
await tick();
|
|
saveChatHandler(_chatId, history);
|
|
};
|
|
|
|
const chatEventHandler = async (event, cb) => {
|
|
console.log(event);
|
|
|
|
if (event.chat_id === $chatId) {
|
|
await tick();
|
|
let message = history.messages[event.message_id];
|
|
|
|
if (message) {
|
|
const type = event?.data?.type ?? null;
|
|
const data = event?.data?.data ?? null;
|
|
|
|
if (type === 'status') {
|
|
if (message?.statusHistory) {
|
|
message.statusHistory.push(data);
|
|
} else {
|
|
message.statusHistory = [data];
|
|
}
|
|
} else if (type === 'chat:completion') {
|
|
chatCompletionEventHandler(data, message, event.chat_id);
|
|
} else if (type === 'chat:tasks:cancel') {
|
|
taskIds = null;
|
|
const responseMessage = history.messages[history.currentId];
|
|
// Set all response messages to done
|
|
for (const messageId of history.messages[responseMessage.parentId].childrenIds) {
|
|
history.messages[messageId].done = true;
|
|
}
|
|
} else if (type === 'chat:message:delta' || type === 'message') {
|
|
message.content += data.content;
|
|
} else if (type === 'chat:message' || type === 'replace') {
|
|
message.content = data.content;
|
|
} else if (type === 'chat:message:files' || type === 'files') {
|
|
message.files = data.files;
|
|
} else if (type === 'chat:message:embeds' || type === 'embeds') {
|
|
message.embeds = data.embeds;
|
|
} else if (type === 'chat:message:error') {
|
|
message.error = data.error;
|
|
} else if (type === 'chat:message:follow_ups') {
|
|
message.followUps = data.follow_ups;
|
|
|
|
if (autoScroll) {
|
|
scrollToBottom('smooth');
|
|
}
|
|
} else if (type === 'chat:title') {
|
|
chatTitle.set(data);
|
|
currentChatPage.set(1);
|
|
await chats.set(await getChatList(localStorage.token, $currentChatPage));
|
|
} else if (type === 'chat:tags') {
|
|
chat = await getChatById(localStorage.token, $chatId);
|
|
allTags.set(await getAllTags(localStorage.token));
|
|
} else if (type === 'source' || type === 'citation') {
|
|
if (data?.type === 'code_execution') {
|
|
// Code execution; update existing code execution by ID, or add new one.
|
|
if (!message?.code_executions) {
|
|
message.code_executions = [];
|
|
}
|
|
|
|
const existingCodeExecutionIndex = message.code_executions.findIndex(
|
|
(execution) => execution.id === data.id
|
|
);
|
|
|
|
if (existingCodeExecutionIndex !== -1) {
|
|
message.code_executions[existingCodeExecutionIndex] = data;
|
|
} else {
|
|
message.code_executions.push(data);
|
|
}
|
|
|
|
message.code_executions = message.code_executions;
|
|
} else {
|
|
// Regular source.
|
|
if (message?.sources) {
|
|
message.sources.push(data);
|
|
} else {
|
|
message.sources = [data];
|
|
}
|
|
}
|
|
} else if (type === 'notification') {
|
|
const toastType = data?.type ?? 'info';
|
|
const toastContent = data?.content ?? '';
|
|
|
|
if (toastType === 'success') {
|
|
toast.success(toastContent);
|
|
} else if (toastType === 'error') {
|
|
toast.error(toastContent);
|
|
} else if (toastType === 'warning') {
|
|
toast.warning(toastContent);
|
|
} else {
|
|
toast.info(toastContent);
|
|
}
|
|
} else if (type === 'confirmation') {
|
|
eventCallback = cb;
|
|
|
|
eventConfirmationInput = false;
|
|
showEventConfirmation = true;
|
|
|
|
eventConfirmationTitle = data.title;
|
|
eventConfirmationMessage = data.message;
|
|
} else if (type === 'execute') {
|
|
eventCallback = cb;
|
|
|
|
try {
|
|
// Use Function constructor to evaluate code in a safer way
|
|
const asyncFunction = new Function(`return (async () => { ${data.code} })()`);
|
|
const result = await asyncFunction(); // Await the result of the async function
|
|
|
|
if (cb) {
|
|
cb(result);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error executing code:', error);
|
|
}
|
|
} else if (type === 'input') {
|
|
eventCallback = cb;
|
|
|
|
eventConfirmationInput = true;
|
|
showEventConfirmation = true;
|
|
|
|
eventConfirmationTitle = data.title;
|
|
eventConfirmationMessage = data.message;
|
|
eventConfirmationInputPlaceholder = data.placeholder;
|
|
eventConfirmationInputValue = data?.value ?? '';
|
|
} else {
|
|
console.log('Unknown message type', data);
|
|
}
|
|
|
|
history.messages[event.message_id] = message;
|
|
}
|
|
}
|
|
};
|
|
|
|
const onMessageHandler = async (event: {
|
|
origin: string;
|
|
data: { type: string; text: string };
|
|
}) => {
|
|
if (event.origin !== window.origin) {
|
|
return;
|
|
}
|
|
|
|
if (event.data.type === 'action:submit') {
|
|
console.debug(event.data.text);
|
|
|
|
if (prompt !== '') {
|
|
await tick();
|
|
submitPrompt(prompt);
|
|
}
|
|
}
|
|
|
|
// Replace with your iframe's origin
|
|
if (event.data.type === 'input:prompt') {
|
|
console.debug(event.data.text);
|
|
|
|
const inputElement = document.getElementById('chat-input');
|
|
|
|
if (inputElement) {
|
|
messageInput?.setText(event.data.text);
|
|
inputElement.focus();
|
|
}
|
|
}
|
|
|
|
if (event.data.type === 'input:prompt:submit') {
|
|
console.debug(event.data.text);
|
|
|
|
if (event.data.text !== '') {
|
|
await tick();
|
|
submitPrompt(event.data.text);
|
|
}
|
|
}
|
|
};
|
|
|
|
const savedModelIds = async () => {
|
|
if (
|
|
$selectedFolder &&
|
|
selectedModels.filter((modelId) => modelId !== '').length > 0 &&
|
|
JSON.stringify($selectedFolder?.data?.model_ids) !== JSON.stringify(selectedModels)
|
|
) {
|
|
const res = await updateFolderById(localStorage.token, $selectedFolder.id, {
|
|
data: {
|
|
model_ids: selectedModels
|
|
}
|
|
});
|
|
}
|
|
};
|
|
|
|
$: if (selectedModels !== null) {
|
|
savedModelIds();
|
|
}
|
|
|
|
let pageSubscribe = null;
|
|
let showControlsSubscribe = null;
|
|
let selectedFolderSubscribe = null;
|
|
|
|
onMount(async () => {
|
|
loading = true;
|
|
console.log('mounted');
|
|
window.addEventListener('message', onMessageHandler);
|
|
$socket?.on('events', chatEventHandler);
|
|
|
|
pageSubscribe = page.subscribe(async (p) => {
|
|
if (p.url.pathname === '/') {
|
|
await tick();
|
|
initNewChat();
|
|
}
|
|
});
|
|
|
|
const storageChatInput = sessionStorage.getItem(
|
|
`chat-input${chatIdProp ? `-${chatIdProp}` : ''}`
|
|
);
|
|
|
|
if (!chatIdProp) {
|
|
loading = false;
|
|
await tick();
|
|
}
|
|
|
|
if (storageChatInput) {
|
|
prompt = '';
|
|
messageInput?.setText('');
|
|
|
|
files = [];
|
|
selectedToolIds = [];
|
|
selectedFilterIds = [];
|
|
webSearchEnabled = false;
|
|
imageGenerationEnabled = false;
|
|
codeInterpreterEnabled = false;
|
|
|
|
try {
|
|
const input = JSON.parse(storageChatInput);
|
|
|
|
if (!$temporaryChatEnabled) {
|
|
messageInput?.setText(input.prompt);
|
|
files = input.files;
|
|
selectedToolIds = input.selectedToolIds;
|
|
selectedFilterIds = input.selectedFilterIds;
|
|
webSearchEnabled = input.webSearchEnabled;
|
|
imageGenerationEnabled = input.imageGenerationEnabled;
|
|
codeInterpreterEnabled = input.codeInterpreterEnabled;
|
|
}
|
|
} catch (e) {}
|
|
}
|
|
|
|
showControlsSubscribe = showControls.subscribe(async (value) => {
|
|
if (controlPane && !$mobile) {
|
|
try {
|
|
if (value) {
|
|
controlPaneComponent.openPane();
|
|
} else {
|
|
controlPane.collapse();
|
|
}
|
|
} catch (e) {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
if (!value) {
|
|
showCallOverlay.set(false);
|
|
showOverview.set(false);
|
|
showArtifacts.set(false);
|
|
showEmbeds.set(false);
|
|
}
|
|
});
|
|
|
|
selectedFolderSubscribe = selectedFolder.subscribe(async (folder) => {
|
|
if (
|
|
folder?.data?.model_ids &&
|
|
JSON.stringify(selectedModels) !== JSON.stringify(folder.data.model_ids)
|
|
) {
|
|
selectedModels = folder.data.model_ids;
|
|
|
|
console.log('Set selectedModels from folder data:', selectedModels);
|
|
}
|
|
});
|
|
|
|
const chatInput = document.getElementById('chat-input');
|
|
chatInput?.focus();
|
|
});
|
|
|
|
onDestroy(() => {
|
|
try {
|
|
pageSubscribe();
|
|
showControlsSubscribe();
|
|
selectedFolderSubscribe();
|
|
chatIdUnsubscriber?.();
|
|
window.removeEventListener('message', onMessageHandler);
|
|
$socket?.off('events', chatEventHandler);
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
});
|
|
|
|
// File upload functions
|
|
|
|
const uploadGoogleDriveFile = async (fileData) => {
|
|
console.log('Starting uploadGoogleDriveFile with:', {
|
|
id: fileData.id,
|
|
name: fileData.name,
|
|
url: fileData.url,
|
|
headers: {
|
|
Authorization: `Bearer ${token}`
|
|
}
|
|
});
|
|
|
|
// Validate input
|
|
if (!fileData?.id || !fileData?.name || !fileData?.url || !fileData?.headers?.Authorization) {
|
|
throw new Error('Invalid file data provided');
|
|
}
|
|
|
|
const tempItemId = uuidv4();
|
|
const fileItem = {
|
|
type: 'file',
|
|
file: '',
|
|
id: null,
|
|
url: fileData.url,
|
|
name: fileData.name,
|
|
collection_name: '',
|
|
status: 'uploading',
|
|
error: '',
|
|
itemId: tempItemId,
|
|
size: 0
|
|
};
|
|
|
|
try {
|
|
files = [...files, fileItem];
|
|
console.log('Processing web file with URL:', fileData.url);
|
|
|
|
// Configure fetch options with proper headers
|
|
const fetchOptions = {
|
|
headers: {
|
|
Authorization: fileData.headers.Authorization,
|
|
Accept: '*/*'
|
|
},
|
|
method: 'GET'
|
|
};
|
|
|
|
// Attempt to fetch the file
|
|
console.log('Fetching file content from Google Drive...');
|
|
const fileResponse = await fetch(fileData.url, fetchOptions);
|
|
|
|
if (!fileResponse.ok) {
|
|
const errorText = await fileResponse.text();
|
|
throw new Error(`Failed to fetch file (${fileResponse.status}): ${errorText}`);
|
|
}
|
|
|
|
// Get content type from response
|
|
const contentType = fileResponse.headers.get('content-type') || 'application/octet-stream';
|
|
console.log('Response received with content-type:', contentType);
|
|
|
|
// Convert response to blob
|
|
console.log('Converting response to blob...');
|
|
const fileBlob = await fileResponse.blob();
|
|
|
|
if (fileBlob.size === 0) {
|
|
throw new Error('Retrieved file is empty');
|
|
}
|
|
|
|
console.log('Blob created:', {
|
|
size: fileBlob.size,
|
|
type: fileBlob.type || contentType
|
|
});
|
|
|
|
// Create File object with proper MIME type
|
|
const file = new File([fileBlob], fileData.name, {
|
|
type: fileBlob.type || contentType
|
|
});
|
|
|
|
console.log('File object created:', {
|
|
name: file.name,
|
|
size: file.size,
|
|
type: file.type
|
|
});
|
|
|
|
if (file.size === 0) {
|
|
throw new Error('Created file is empty');
|
|
}
|
|
|
|
// If the file is an audio file, provide the language for STT.
|
|
let metadata = null;
|
|
if (
|
|
(file.type.startsWith('audio/') || file.type.startsWith('video/')) &&
|
|
$settings?.audio?.stt?.language
|
|
) {
|
|
metadata = {
|
|
language: $settings?.audio?.stt?.language
|
|
};
|
|
}
|
|
|
|
// Upload file to server
|
|
console.log('Uploading file to server...');
|
|
const uploadedFile = await uploadFile(localStorage.token, file, metadata);
|
|
|
|
if (!uploadedFile) {
|
|
throw new Error('Server returned null response for file upload');
|
|
}
|
|
|
|
console.log('File uploaded successfully:', uploadedFile);
|
|
|
|
// Update file item with upload results
|
|
fileItem.status = 'uploaded';
|
|
fileItem.file = uploadedFile;
|
|
fileItem.id = uploadedFile.id;
|
|
fileItem.size = file.size;
|
|
fileItem.collection_name = uploadedFile?.meta?.collection_name;
|
|
fileItem.url = `${WEBUI_API_BASE_URL}/files/${uploadedFile.id}`;
|
|
|
|
files = files;
|
|
toast.success($i18n.t('File uploaded successfully'));
|
|
} catch (e) {
|
|
console.error('Error uploading file:', e);
|
|
files = files.filter((f) => f.itemId !== tempItemId);
|
|
toast.error(
|
|
$i18n.t('Error uploading file: {{error}}', {
|
|
error: e.message || 'Unknown error'
|
|
})
|
|
);
|
|
}
|
|
};
|
|
|
|
const uploadWeb = async (url) => {
|
|
console.log(url);
|
|
|
|
const fileItem = {
|
|
type: 'text',
|
|
name: url,
|
|
collection_name: '',
|
|
status: 'uploading',
|
|
url: url,
|
|
error: ''
|
|
};
|
|
|
|
try {
|
|
files = [...files, fileItem];
|
|
const res = await processWeb(localStorage.token, '', url);
|
|
|
|
if (res) {
|
|
fileItem.status = 'uploaded';
|
|
fileItem.collection_name = res.collection_name;
|
|
fileItem.file = {
|
|
...res.file,
|
|
...fileItem.file
|
|
};
|
|
|
|
files = files;
|
|
}
|
|
} catch (e) {
|
|
// Remove the failed doc from the files array
|
|
files = files.filter((f) => f.name !== url);
|
|
toast.error(JSON.stringify(e));
|
|
}
|
|
};
|
|
|
|
const uploadYoutubeTranscription = async (url) => {
|
|
console.log(url);
|
|
|
|
const fileItem = {
|
|
type: 'text',
|
|
name: url,
|
|
collection_name: '',
|
|
status: 'uploading',
|
|
context: 'full',
|
|
url: url,
|
|
error: ''
|
|
};
|
|
|
|
try {
|
|
files = [...files, fileItem];
|
|
const res = await processYoutubeVideo(localStorage.token, url);
|
|
|
|
if (res) {
|
|
fileItem.status = 'uploaded';
|
|
fileItem.collection_name = res.collection_name;
|
|
fileItem.file = {
|
|
...res.file,
|
|
...fileItem.file
|
|
};
|
|
files = files;
|
|
}
|
|
} catch (e) {
|
|
// Remove the failed doc from the files array
|
|
files = files.filter((f) => f.name !== url);
|
|
toast.error(`${e}`);
|
|
}
|
|
};
|
|
|
|
//////////////////////////
|
|
// Web functions
|
|
//////////////////////////
|
|
|
|
const initNewChat = async () => {
|
|
console.log('initNewChat');
|
|
if ($user?.role !== 'admin' && $user?.permissions?.chat?.temporary_enforced) {
|
|
await temporaryChatEnabled.set(true);
|
|
}
|
|
|
|
if ($settings?.temporaryChatByDefault ?? false) {
|
|
if ($temporaryChatEnabled === false) {
|
|
await temporaryChatEnabled.set(true);
|
|
} else if ($temporaryChatEnabled === null) {
|
|
// if set to null set to false; refer to temp chat toggle click handler
|
|
await temporaryChatEnabled.set(false);
|
|
}
|
|
}
|
|
|
|
const availableModels = $models
|
|
.filter((m) => !(m?.info?.meta?.hidden ?? false))
|
|
.map((m) => m.id);
|
|
|
|
if ($page.url.searchParams.get('models') || $page.url.searchParams.get('model')) {
|
|
const urlModels = (
|
|
$page.url.searchParams.get('models') ||
|
|
$page.url.searchParams.get('model') ||
|
|
''
|
|
)?.split(',');
|
|
|
|
if (urlModels.length === 1) {
|
|
const m = $models.find((m) => m.id === urlModels[0]);
|
|
if (!m) {
|
|
const modelSelectorButton = document.getElementById('model-selector-0-button');
|
|
if (modelSelectorButton) {
|
|
modelSelectorButton.click();
|
|
await tick();
|
|
|
|
const modelSelectorInput = document.getElementById('model-search-input');
|
|
if (modelSelectorInput) {
|
|
modelSelectorInput.focus();
|
|
modelSelectorInput.value = urlModels[0];
|
|
modelSelectorInput.dispatchEvent(new Event('input'));
|
|
}
|
|
}
|
|
} else {
|
|
selectedModels = urlModels;
|
|
}
|
|
} else {
|
|
selectedModels = urlModels;
|
|
}
|
|
|
|
selectedModels = selectedModels.filter((modelId) =>
|
|
$models.map((m) => m.id).includes(modelId)
|
|
);
|
|
} else {
|
|
if ($selectedFolder?.data?.model_ids) {
|
|
selectedModels = $selectedFolder?.data?.model_ids;
|
|
} else {
|
|
if (sessionStorage.selectedModels) {
|
|
selectedModels = JSON.parse(sessionStorage.selectedModels);
|
|
sessionStorage.removeItem('selectedModels');
|
|
} else {
|
|
if ($settings?.models) {
|
|
selectedModels = $settings?.models;
|
|
} else if ($config?.default_models) {
|
|
console.log($config?.default_models.split(',') ?? '');
|
|
selectedModels = $config?.default_models.split(',');
|
|
}
|
|
}
|
|
}
|
|
|
|
selectedModels = selectedModels.filter((modelId) => availableModels.includes(modelId));
|
|
}
|
|
|
|
if (selectedModels.length === 0 || (selectedModels.length === 1 && selectedModels[0] === '')) {
|
|
if (availableModels.length > 0) {
|
|
selectedModels = [availableModels?.at(0) ?? ''];
|
|
} else {
|
|
selectedModels = [''];
|
|
}
|
|
}
|
|
|
|
await showControls.set(false);
|
|
await showCallOverlay.set(false);
|
|
await showOverview.set(false);
|
|
await showArtifacts.set(false);
|
|
|
|
if ($page.url.pathname.includes('/c/')) {
|
|
window.history.replaceState(history.state, '', `/`);
|
|
}
|
|
|
|
autoScroll = true;
|
|
|
|
resetInput();
|
|
await chatId.set('');
|
|
await chatTitle.set('');
|
|
|
|
history = {
|
|
messages: {},
|
|
currentId: null
|
|
};
|
|
|
|
chatFiles = [];
|
|
params = {};
|
|
|
|
if ($page.url.searchParams.get('youtube')) {
|
|
uploadYoutubeTranscription(
|
|
`https://www.youtube.com/watch?v=${$page.url.searchParams.get('youtube')}`
|
|
);
|
|
}
|
|
|
|
if ($page.url.searchParams.get('load-url')) {
|
|
await uploadWeb($page.url.searchParams.get('load-url'));
|
|
}
|
|
|
|
if ($page.url.searchParams.get('web-search') === 'true') {
|
|
webSearchEnabled = true;
|
|
}
|
|
|
|
if ($page.url.searchParams.get('image-generation') === 'true') {
|
|
imageGenerationEnabled = true;
|
|
}
|
|
|
|
if ($page.url.searchParams.get('code-interpreter') === 'true') {
|
|
codeInterpreterEnabled = true;
|
|
}
|
|
|
|
if ($page.url.searchParams.get('tools')) {
|
|
selectedToolIds = $page.url.searchParams
|
|
.get('tools')
|
|
?.split(',')
|
|
.map((id) => id.trim())
|
|
.filter((id) => id);
|
|
} else if ($page.url.searchParams.get('tool-ids')) {
|
|
selectedToolIds = $page.url.searchParams
|
|
.get('tool-ids')
|
|
?.split(',')
|
|
.map((id) => id.trim())
|
|
.filter((id) => id);
|
|
}
|
|
|
|
if ($page.url.searchParams.get('call') === 'true') {
|
|
showCallOverlay.set(true);
|
|
showControls.set(true);
|
|
}
|
|
|
|
if ($page.url.searchParams.get('q')) {
|
|
const q = $page.url.searchParams.get('q') ?? '';
|
|
messageInput?.setText(q);
|
|
|
|
if (q) {
|
|
if (($page.url.searchParams.get('submit') ?? 'true') === 'true') {
|
|
await tick();
|
|
submitPrompt(q);
|
|
}
|
|
}
|
|
}
|
|
|
|
selectedModels = selectedModels.map((modelId) =>
|
|
$models.map((m) => m.id).includes(modelId) ? modelId : ''
|
|
);
|
|
|
|
const userSettings = await getUserSettings(localStorage.token);
|
|
|
|
if (userSettings) {
|
|
settings.set(userSettings.ui);
|
|
} else {
|
|
settings.set(JSON.parse(localStorage.getItem('settings') ?? '{}'));
|
|
}
|
|
|
|
const chatInput = document.getElementById('chat-input');
|
|
setTimeout(() => chatInput?.focus(), 0);
|
|
};
|
|
|
|
const loadChat = async () => {
|
|
chatId.set(chatIdProp);
|
|
|
|
if ($temporaryChatEnabled) {
|
|
temporaryChatEnabled.set(false);
|
|
}
|
|
|
|
chat = await getChatById(localStorage.token, $chatId).catch(async (error) => {
|
|
await goto('/');
|
|
return null;
|
|
});
|
|
|
|
if (chat) {
|
|
tags = await getTagsById(localStorage.token, $chatId).catch(async (error) => {
|
|
return [];
|
|
});
|
|
|
|
const chatContent = chat.chat;
|
|
|
|
if (chatContent) {
|
|
console.log(chatContent);
|
|
|
|
selectedModels =
|
|
(chatContent?.models ?? undefined) !== undefined
|
|
? chatContent.models
|
|
: [chatContent.models ?? ''];
|
|
|
|
if (!($user?.role === 'admin' || ($user?.permissions?.chat?.multiple_models ?? true))) {
|
|
selectedModels = selectedModels.length > 0 ? [selectedModels[0]] : [''];
|
|
}
|
|
|
|
oldSelectedModelIds = selectedModels;
|
|
|
|
history =
|
|
(chatContent?.history ?? undefined) !== undefined
|
|
? chatContent.history
|
|
: convertMessagesToHistory(chatContent.messages);
|
|
|
|
chatTitle.set(chatContent.title);
|
|
|
|
const userSettings = await getUserSettings(localStorage.token);
|
|
|
|
if (userSettings) {
|
|
await settings.set(userSettings.ui);
|
|
} else {
|
|
await settings.set(JSON.parse(localStorage.getItem('settings') ?? '{}'));
|
|
}
|
|
|
|
params = chatContent?.params ?? {};
|
|
chatFiles = chatContent?.files ?? [];
|
|
|
|
autoScroll = true;
|
|
await tick();
|
|
|
|
if (history.currentId) {
|
|
for (const message of Object.values(history.messages)) {
|
|
if (message.role === 'assistant') {
|
|
message.done = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
const taskRes = await getTaskIdsByChatId(localStorage.token, $chatId).catch((error) => {
|
|
return null;
|
|
});
|
|
|
|
if (taskRes) {
|
|
taskIds = taskRes.task_ids;
|
|
}
|
|
|
|
await tick();
|
|
|
|
return true;
|
|
} else {
|
|
return null;
|
|
}
|
|
}
|
|
};
|
|
|
|
const scrollToBottom = async (behavior = 'auto') => {
|
|
await tick();
|
|
if (messagesContainerElement) {
|
|
messagesContainerElement.scrollTo({
|
|
top: messagesContainerElement.scrollHeight,
|
|
behavior
|
|
});
|
|
}
|
|
};
|
|
const chatCompletedHandler = async (chatId, modelId, responseMessageId, messages) => {
|
|
const res = await chatCompleted(localStorage.token, {
|
|
model: modelId,
|
|
messages: messages.map((m) => ({
|
|
id: m.id,
|
|
role: m.role,
|
|
content: m.content,
|
|
info: m.info ? m.info : undefined,
|
|
timestamp: m.timestamp,
|
|
...(m.usage ? { usage: m.usage } : {}),
|
|
...(m.sources ? { sources: m.sources } : {})
|
|
})),
|
|
filter_ids: selectedFilterIds.length > 0 ? selectedFilterIds : undefined,
|
|
model_item: $models.find((m) => m.id === modelId),
|
|
chat_id: chatId,
|
|
session_id: $socket?.id,
|
|
id: responseMessageId
|
|
}).catch((error) => {
|
|
toast.error(`${error}`);
|
|
messages.at(-1).error = { content: error };
|
|
|
|
return null;
|
|
});
|
|
|
|
if (res !== null && res.messages) {
|
|
// Update chat history with the new messages
|
|
for (const message of res.messages) {
|
|
if (message?.id) {
|
|
// Add null check for message and message.id
|
|
history.messages[message.id] = {
|
|
...history.messages[message.id],
|
|
...(history.messages[message.id].content !== message.content
|
|
? { originalContent: history.messages[message.id].content }
|
|
: {}),
|
|
...message
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
await tick();
|
|
|
|
if ($chatId == chatId) {
|
|
if (!$temporaryChatEnabled) {
|
|
chat = await updateChatById(localStorage.token, chatId, {
|
|
models: selectedModels,
|
|
messages: messages,
|
|
history: history,
|
|
params: params,
|
|
files: chatFiles
|
|
});
|
|
|
|
currentChatPage.set(1);
|
|
await chats.set(await getChatList(localStorage.token, $currentChatPage));
|
|
}
|
|
}
|
|
|
|
taskIds = null;
|
|
};
|
|
|
|
const chatActionHandler = async (chatId, actionId, modelId, responseMessageId, event = null) => {
|
|
const messages = createMessagesList(history, responseMessageId);
|
|
|
|
const res = await chatAction(localStorage.token, actionId, {
|
|
model: modelId,
|
|
messages: messages.map((m) => ({
|
|
id: m.id,
|
|
role: m.role,
|
|
content: m.content,
|
|
info: m.info ? m.info : undefined,
|
|
timestamp: m.timestamp,
|
|
...(m.sources ? { sources: m.sources } : {})
|
|
})),
|
|
...(event ? { event: event } : {}),
|
|
model_item: $models.find((m) => m.id === modelId),
|
|
chat_id: chatId,
|
|
session_id: $socket?.id,
|
|
id: responseMessageId
|
|
}).catch((error) => {
|
|
toast.error(`${error}`);
|
|
messages.at(-1).error = { content: error };
|
|
return null;
|
|
});
|
|
|
|
if (res !== null && res.messages) {
|
|
// Update chat history with the new messages
|
|
for (const message of res.messages) {
|
|
history.messages[message.id] = {
|
|
...history.messages[message.id],
|
|
...(history.messages[message.id].content !== message.content
|
|
? { originalContent: history.messages[message.id].content }
|
|
: {}),
|
|
...message
|
|
};
|
|
}
|
|
}
|
|
|
|
if ($chatId == chatId) {
|
|
if (!$temporaryChatEnabled) {
|
|
chat = await updateChatById(localStorage.token, chatId, {
|
|
models: selectedModels,
|
|
messages: messages,
|
|
history: history,
|
|
params: params,
|
|
files: chatFiles
|
|
});
|
|
|
|
currentChatPage.set(1);
|
|
await chats.set(await getChatList(localStorage.token, $currentChatPage));
|
|
}
|
|
}
|
|
};
|
|
|
|
const getChatEventEmitter = async (modelId: string, chatId: string = '') => {
|
|
return setInterval(() => {
|
|
$socket?.emit('usage', {
|
|
action: 'chat',
|
|
model: modelId,
|
|
chat_id: chatId
|
|
});
|
|
}, 1000);
|
|
};
|
|
|
|
const createMessagePair = async (userPrompt) => {
|
|
messageInput?.setText('');
|
|
if (selectedModels.length === 0) {
|
|
toast.error($i18n.t('Model not selected'));
|
|
} else {
|
|
const modelId = selectedModels[0];
|
|
const model = $models.filter((m) => m.id === modelId).at(0);
|
|
|
|
const messages = createMessagesList(history, history.currentId);
|
|
const parentMessage = messages.length !== 0 ? messages.at(-1) : null;
|
|
|
|
const userMessageId = uuidv4();
|
|
const responseMessageId = uuidv4();
|
|
|
|
const userMessage = {
|
|
id: userMessageId,
|
|
parentId: parentMessage ? parentMessage.id : null,
|
|
childrenIds: [responseMessageId],
|
|
role: 'user',
|
|
content: userPrompt ? userPrompt : `[PROMPT] ${userMessageId}`,
|
|
timestamp: Math.floor(Date.now() / 1000)
|
|
};
|
|
|
|
const responseMessage = {
|
|
id: responseMessageId,
|
|
parentId: userMessageId,
|
|
childrenIds: [],
|
|
role: 'assistant',
|
|
content: `[RESPONSE] ${responseMessageId}`,
|
|
done: true,
|
|
|
|
model: modelId,
|
|
modelName: model.name ?? model.id,
|
|
modelIdx: 0,
|
|
timestamp: Math.floor(Date.now() / 1000)
|
|
};
|
|
|
|
if (parentMessage) {
|
|
parentMessage.childrenIds.push(userMessageId);
|
|
history.messages[parentMessage.id] = parentMessage;
|
|
}
|
|
history.messages[userMessageId] = userMessage;
|
|
history.messages[responseMessageId] = responseMessage;
|
|
|
|
history.currentId = responseMessageId;
|
|
|
|
await tick();
|
|
|
|
if (autoScroll) {
|
|
scrollToBottom();
|
|
}
|
|
|
|
if (messages.length === 0) {
|
|
await initChatHandler(history);
|
|
} else {
|
|
await saveChatHandler($chatId, history);
|
|
}
|
|
}
|
|
};
|
|
|
|
const addMessages = async ({ modelId, parentId, messages }) => {
|
|
const model = $models.filter((m) => m.id === modelId).at(0);
|
|
|
|
let parentMessage = history.messages[parentId];
|
|
let currentParentId = parentMessage ? parentMessage.id : null;
|
|
for (const message of messages) {
|
|
let messageId = uuidv4();
|
|
|
|
if (message.role === 'user') {
|
|
const userMessage = {
|
|
id: messageId,
|
|
parentId: currentParentId,
|
|
childrenIds: [],
|
|
timestamp: Math.floor(Date.now() / 1000),
|
|
...message
|
|
};
|
|
|
|
if (parentMessage) {
|
|
parentMessage.childrenIds.push(messageId);
|
|
history.messages[parentMessage.id] = parentMessage;
|
|
}
|
|
|
|
history.messages[messageId] = userMessage;
|
|
parentMessage = userMessage;
|
|
currentParentId = messageId;
|
|
} else {
|
|
const responseMessage = {
|
|
id: messageId,
|
|
parentId: currentParentId,
|
|
childrenIds: [],
|
|
done: true,
|
|
model: model.id,
|
|
modelName: model.name ?? model.id,
|
|
modelIdx: 0,
|
|
timestamp: Math.floor(Date.now() / 1000),
|
|
...message
|
|
};
|
|
|
|
if (parentMessage) {
|
|
parentMessage.childrenIds.push(messageId);
|
|
history.messages[parentMessage.id] = parentMessage;
|
|
}
|
|
|
|
history.messages[messageId] = responseMessage;
|
|
parentMessage = responseMessage;
|
|
currentParentId = messageId;
|
|
}
|
|
}
|
|
|
|
history.currentId = currentParentId;
|
|
await tick();
|
|
|
|
if (autoScroll) {
|
|
scrollToBottom();
|
|
}
|
|
|
|
if (messages.length === 0) {
|
|
await initChatHandler(history);
|
|
} else {
|
|
await saveChatHandler($chatId, history);
|
|
}
|
|
};
|
|
|
|
const chatCompletionEventHandler = async (data, message, chatId) => {
|
|
const { id, done, choices, content, sources, selected_model_id, error, usage } = data;
|
|
|
|
if (error) {
|
|
await handleOpenAIError(error, message);
|
|
}
|
|
|
|
if (sources && !message?.sources) {
|
|
message.sources = sources;
|
|
}
|
|
|
|
if (choices) {
|
|
if (choices[0]?.message?.content) {
|
|
// Non-stream response
|
|
message.content += choices[0]?.message?.content;
|
|
} else {
|
|
// Stream response
|
|
let value = choices[0]?.delta?.content ?? '';
|
|
if (message.content == '' && value == '\n') {
|
|
console.log('Empty response');
|
|
} else {
|
|
message.content += value;
|
|
|
|
if (navigator.vibrate && ($settings?.hapticFeedback ?? false)) {
|
|
navigator.vibrate(5);
|
|
}
|
|
|
|
// Emit chat event for TTS
|
|
const messageContentParts = getMessageContentParts(
|
|
removeAllDetails(message.content),
|
|
$config?.audio?.tts?.split_on ?? 'punctuation'
|
|
);
|
|
messageContentParts.pop();
|
|
|
|
// dispatch only last sentence and make sure it hasn't been dispatched before
|
|
if (
|
|
messageContentParts.length > 0 &&
|
|
messageContentParts[messageContentParts.length - 1] !== message.lastSentence
|
|
) {
|
|
message.lastSentence = messageContentParts[messageContentParts.length - 1];
|
|
eventTarget.dispatchEvent(
|
|
new CustomEvent('chat', {
|
|
detail: {
|
|
id: message.id,
|
|
content: messageContentParts[messageContentParts.length - 1]
|
|
}
|
|
})
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (content) {
|
|
// REALTIME_CHAT_SAVE is disabled
|
|
message.content = content;
|
|
|
|
if (navigator.vibrate && ($settings?.hapticFeedback ?? false)) {
|
|
navigator.vibrate(5);
|
|
}
|
|
|
|
// Emit chat event for TTS
|
|
const messageContentParts = getMessageContentParts(
|
|
removeAllDetails(message.content),
|
|
$config?.audio?.tts?.split_on ?? 'punctuation'
|
|
);
|
|
messageContentParts.pop();
|
|
|
|
// dispatch only last sentence and make sure it hasn't been dispatched before
|
|
if (
|
|
messageContentParts.length > 0 &&
|
|
messageContentParts[messageContentParts.length - 1] !== message.lastSentence
|
|
) {
|
|
message.lastSentence = messageContentParts[messageContentParts.length - 1];
|
|
eventTarget.dispatchEvent(
|
|
new CustomEvent('chat', {
|
|
detail: {
|
|
id: message.id,
|
|
content: messageContentParts[messageContentParts.length - 1]
|
|
}
|
|
})
|
|
);
|
|
}
|
|
}
|
|
|
|
if (selected_model_id) {
|
|
message.selectedModelId = selected_model_id;
|
|
message.arena = true;
|
|
}
|
|
|
|
if (usage) {
|
|
message.usage = usage;
|
|
}
|
|
|
|
history.messages[message.id] = message;
|
|
|
|
if (done) {
|
|
message.done = true;
|
|
|
|
if ($settings.responseAutoCopy) {
|
|
copyToClipboard(message.content);
|
|
}
|
|
|
|
if ($settings.responseAutoPlayback && !$showCallOverlay) {
|
|
await tick();
|
|
document.getElementById(`speak-button-${message.id}`)?.click();
|
|
}
|
|
|
|
// Emit chat event for TTS
|
|
let lastMessageContentPart =
|
|
getMessageContentParts(
|
|
removeAllDetails(message.content),
|
|
$config?.audio?.tts?.split_on ?? 'punctuation'
|
|
)?.at(-1) ?? '';
|
|
if (lastMessageContentPart) {
|
|
eventTarget.dispatchEvent(
|
|
new CustomEvent('chat', {
|
|
detail: { id: message.id, content: lastMessageContentPart }
|
|
})
|
|
);
|
|
}
|
|
eventTarget.dispatchEvent(
|
|
new CustomEvent('chat:finish', {
|
|
detail: {
|
|
id: message.id,
|
|
content: message.content
|
|
}
|
|
})
|
|
);
|
|
|
|
history.messages[message.id] = message;
|
|
|
|
await tick();
|
|
if (autoScroll) {
|
|
scrollToBottom();
|
|
}
|
|
|
|
await chatCompletedHandler(
|
|
chatId,
|
|
message.model,
|
|
message.id,
|
|
createMessagesList(history, message.id)
|
|
);
|
|
}
|
|
|
|
console.log(data);
|
|
await tick();
|
|
|
|
if (autoScroll) {
|
|
scrollToBottom();
|
|
}
|
|
};
|
|
|
|
//////////////////////////
|
|
// Chat functions
|
|
//////////////////////////
|
|
|
|
const submitPrompt = async (userPrompt, { _raw = false } = {}) => {
|
|
console.log('submitPrompt', userPrompt, $chatId);
|
|
|
|
const _selectedModels = selectedModels.map((modelId) =>
|
|
$models.map((m) => m.id).includes(modelId) ? modelId : ''
|
|
);
|
|
|
|
if (JSON.stringify(selectedModels) !== JSON.stringify(_selectedModels)) {
|
|
selectedModels = _selectedModels;
|
|
}
|
|
|
|
if (userPrompt === '' && files.length === 0) {
|
|
toast.error($i18n.t('Please enter a prompt'));
|
|
return;
|
|
}
|
|
if (selectedModels.includes('')) {
|
|
toast.error($i18n.t('Model not selected'));
|
|
return;
|
|
}
|
|
|
|
if (
|
|
files.length > 0 &&
|
|
files.filter((file) => file.type !== 'image' && file.status === 'uploading').length > 0
|
|
) {
|
|
toast.error(
|
|
$i18n.t(`Oops! There are files still uploading. Please wait for the upload to complete.`)
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (
|
|
($config?.file?.max_count ?? null) !== null &&
|
|
files.length + chatFiles.length > $config?.file?.max_count
|
|
) {
|
|
toast.error(
|
|
$i18n.t(`You can only chat with a maximum of {{maxCount}} file(s) at a time.`, {
|
|
maxCount: $config?.file?.max_count
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (history?.currentId) {
|
|
const lastMessage = history.messages[history.currentId];
|
|
if (lastMessage.done != true) {
|
|
// Response not done
|
|
return;
|
|
}
|
|
|
|
if (lastMessage.error && !lastMessage.content) {
|
|
// Error in response
|
|
toast.error($i18n.t(`Oops! There was an error in the previous response.`));
|
|
return;
|
|
}
|
|
}
|
|
|
|
messageInput?.setText('');
|
|
prompt = '';
|
|
|
|
const messages = createMessagesList(history, history.currentId);
|
|
const _files = JSON.parse(JSON.stringify(files));
|
|
|
|
chatFiles.push(
|
|
..._files.filter((item) =>
|
|
['doc', 'text', 'file', 'note', 'chat', 'folder', 'collection'].includes(item.type)
|
|
)
|
|
);
|
|
chatFiles = chatFiles.filter(
|
|
// Remove duplicates
|
|
(item, index, array) =>
|
|
array.findIndex((i) => JSON.stringify(i) === JSON.stringify(item)) === index
|
|
);
|
|
|
|
files = [];
|
|
messageInput?.setText('');
|
|
|
|
// Create user message
|
|
let userMessageId = uuidv4();
|
|
let userMessage = {
|
|
id: userMessageId,
|
|
parentId: messages.length !== 0 ? messages.at(-1).id : null,
|
|
childrenIds: [],
|
|
role: 'user',
|
|
content: userPrompt,
|
|
files: _files.length > 0 ? _files : undefined,
|
|
timestamp: Math.floor(Date.now() / 1000), // Unix epoch
|
|
models: selectedModels
|
|
};
|
|
|
|
// Add message to history and Set currentId to messageId
|
|
history.messages[userMessageId] = userMessage;
|
|
history.currentId = userMessageId;
|
|
|
|
// Append messageId to childrenIds of parent message
|
|
if (messages.length !== 0) {
|
|
history.messages[messages.at(-1).id].childrenIds.push(userMessageId);
|
|
}
|
|
|
|
// focus on chat input
|
|
const chatInput = document.getElementById('chat-input');
|
|
chatInput?.focus();
|
|
|
|
saveSessionSelectedModels();
|
|
|
|
await sendMessage(history, userMessageId, { newChat: true });
|
|
};
|
|
|
|
const sendMessage = async (
|
|
_history,
|
|
parentId: string,
|
|
{
|
|
messages = null,
|
|
modelId = null,
|
|
modelIdx = null,
|
|
newChat = false
|
|
}: {
|
|
messages?: any[] | null;
|
|
modelId?: string | null;
|
|
modelIdx?: number | null;
|
|
newChat?: boolean;
|
|
} = {}
|
|
) => {
|
|
if (autoScroll) {
|
|
scrollToBottom();
|
|
}
|
|
|
|
let _chatId = JSON.parse(JSON.stringify($chatId));
|
|
_history = JSON.parse(JSON.stringify(_history));
|
|
|
|
const responseMessageIds: Record<PropertyKey, string> = {};
|
|
// If modelId is provided, use it, else use selected model
|
|
let selectedModelIds = modelId
|
|
? [modelId]
|
|
: atSelectedModel !== undefined
|
|
? [atSelectedModel.id]
|
|
: selectedModels;
|
|
|
|
// Create response messages for each selected model
|
|
for (const [_modelIdx, modelId] of selectedModelIds.entries()) {
|
|
const model = $models.filter((m) => m.id === modelId).at(0);
|
|
|
|
if (model) {
|
|
let responseMessageId = uuidv4();
|
|
let responseMessage = {
|
|
parentId: parentId,
|
|
id: responseMessageId,
|
|
childrenIds: [],
|
|
role: 'assistant',
|
|
content: '',
|
|
model: model.id,
|
|
modelName: model.name ?? model.id,
|
|
modelIdx: modelIdx ? modelIdx : _modelIdx,
|
|
timestamp: Math.floor(Date.now() / 1000) // Unix epoch
|
|
};
|
|
|
|
// Add message to history and Set currentId to messageId
|
|
history.messages[responseMessageId] = responseMessage;
|
|
history.currentId = responseMessageId;
|
|
|
|
// Append messageId to childrenIds of parent message
|
|
if (parentId !== null && history.messages[parentId]) {
|
|
// Add null check before accessing childrenIds
|
|
history.messages[parentId].childrenIds = [
|
|
...history.messages[parentId].childrenIds,
|
|
responseMessageId
|
|
];
|
|
}
|
|
|
|
responseMessageIds[`${modelId}-${modelIdx ? modelIdx : _modelIdx}`] = responseMessageId;
|
|
}
|
|
}
|
|
history = history;
|
|
|
|
// Create new chat if newChat is true and first user message
|
|
if (newChat && _history.messages[_history.currentId].parentId === null) {
|
|
_chatId = await initChatHandler(_history);
|
|
}
|
|
|
|
await tick();
|
|
|
|
_history = JSON.parse(JSON.stringify(history));
|
|
// Save chat after all messages have been created
|
|
await saveChatHandler(_chatId, _history);
|
|
|
|
await Promise.all(
|
|
selectedModelIds.map(async (modelId, _modelIdx) => {
|
|
console.log('modelId', modelId);
|
|
const model = $models.filter((m) => m.id === modelId).at(0);
|
|
|
|
if (model) {
|
|
// If there are image files, check if model is vision capable
|
|
const hasImages = createMessagesList(_history, parentId).some((message) =>
|
|
message.files?.some((file) => file.type === 'image')
|
|
);
|
|
|
|
if (hasImages && !(model.info?.meta?.capabilities?.vision ?? true)) {
|
|
toast.error(
|
|
$i18n.t('Model {{modelName}} is not vision capable', {
|
|
modelName: model.name ?? model.id
|
|
})
|
|
);
|
|
}
|
|
|
|
let responseMessageId =
|
|
responseMessageIds[`${modelId}-${modelIdx ? modelIdx : _modelIdx}`];
|
|
const chatEventEmitter = await getChatEventEmitter(model.id, _chatId);
|
|
|
|
scrollToBottom();
|
|
await sendMessageSocket(
|
|
model,
|
|
messages && messages.length > 0
|
|
? messages
|
|
: createMessagesList(_history, responseMessageId),
|
|
_history,
|
|
responseMessageId,
|
|
_chatId
|
|
);
|
|
|
|
if (chatEventEmitter) clearInterval(chatEventEmitter);
|
|
} else {
|
|
toast.error($i18n.t(`Model {{modelId}} not found`, { modelId }));
|
|
}
|
|
})
|
|
);
|
|
|
|
currentChatPage.set(1);
|
|
chats.set(await getChatList(localStorage.token, $currentChatPage));
|
|
};
|
|
|
|
const getFeatures = () => {
|
|
let features = {};
|
|
|
|
if ($config?.features)
|
|
features = {
|
|
image_generation:
|
|
$config?.features?.enable_image_generation &&
|
|
($user?.role === 'admin' || $user?.permissions?.features?.image_generation)
|
|
? imageGenerationEnabled
|
|
: false,
|
|
code_interpreter:
|
|
$config?.features?.enable_code_interpreter &&
|
|
($user?.role === 'admin' || $user?.permissions?.features?.code_interpreter)
|
|
? codeInterpreterEnabled
|
|
: false,
|
|
web_search:
|
|
$config?.features?.enable_web_search &&
|
|
($user?.role === 'admin' || $user?.permissions?.features?.web_search)
|
|
? webSearchEnabled
|
|
: false
|
|
};
|
|
|
|
const currentModels = atSelectedModel?.id ? [atSelectedModel.id] : selectedModels;
|
|
if (
|
|
currentModels.filter(
|
|
(model) => $models.find((m) => m.id === model)?.info?.meta?.capabilities?.web_search ?? true
|
|
).length === currentModels.length
|
|
) {
|
|
if ($config?.features?.enable_web_search && ($settings?.webSearch ?? false) === 'always') {
|
|
features = { ...features, web_search: true };
|
|
}
|
|
}
|
|
|
|
if ($settings?.memory ?? false) {
|
|
features = { ...features, memory: true };
|
|
}
|
|
|
|
return features;
|
|
};
|
|
|
|
const sendMessageSocket = async (model, _messages, _history, responseMessageId, _chatId) => {
|
|
const responseMessage = _history.messages[responseMessageId];
|
|
const userMessage = _history.messages[responseMessage.parentId];
|
|
|
|
const chatMessageFiles = _messages
|
|
.filter((message) => message.files)
|
|
.flatMap((message) => message.files);
|
|
|
|
// Filter chatFiles to only include files that are in the chatMessageFiles
|
|
chatFiles = chatFiles.filter((item) => {
|
|
const fileExists = chatMessageFiles.some((messageFile) => messageFile.id === item.id);
|
|
return fileExists;
|
|
});
|
|
|
|
let files = JSON.parse(JSON.stringify(chatFiles));
|
|
files.push(
|
|
...(userMessage?.files ?? []).filter((item) =>
|
|
['doc', 'text', 'file', 'note', 'chat', 'collection'].includes(item.type)
|
|
)
|
|
);
|
|
// Remove duplicates
|
|
files = files.filter(
|
|
(item, index, array) =>
|
|
array.findIndex((i) => JSON.stringify(i) === JSON.stringify(item)) === index
|
|
);
|
|
|
|
scrollToBottom();
|
|
eventTarget.dispatchEvent(
|
|
new CustomEvent('chat:start', {
|
|
detail: {
|
|
id: responseMessageId
|
|
}
|
|
})
|
|
);
|
|
await tick();
|
|
|
|
let userLocation;
|
|
if ($settings?.userLocation) {
|
|
userLocation = await getAndUpdateUserLocation(localStorage.token).catch((err) => {
|
|
console.error(err);
|
|
return undefined;
|
|
});
|
|
}
|
|
|
|
const stream =
|
|
model?.info?.params?.stream_response ??
|
|
$settings?.params?.stream_response ??
|
|
params?.stream_response ??
|
|
true;
|
|
|
|
let messages = [
|
|
params?.system || $settings.system
|
|
? {
|
|
role: 'system',
|
|
content: `${params?.system ?? $settings?.system ?? ''}`
|
|
}
|
|
: undefined,
|
|
..._messages.map((message) => ({
|
|
...message,
|
|
content: processDetails(message.content)
|
|
}))
|
|
].filter((message) => message);
|
|
|
|
messages = messages
|
|
.map((message, idx, arr) => ({
|
|
role: message.role,
|
|
...((message.files?.filter((file) => file.type === 'image').length > 0 ?? false) &&
|
|
message.role === 'user'
|
|
? {
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: message?.merged?.content ?? message.content
|
|
},
|
|
...message.files
|
|
.filter((file) => file.type === 'image')
|
|
.map((file) => ({
|
|
type: 'image_url',
|
|
image_url: {
|
|
url: file.url
|
|
}
|
|
}))
|
|
]
|
|
}
|
|
: {
|
|
content: message?.merged?.content ?? message.content
|
|
})
|
|
}))
|
|
.filter((message) => message?.role === 'user' || message?.content?.trim());
|
|
|
|
const toolIds = [];
|
|
const toolServerIds = [];
|
|
|
|
for (const toolId of selectedToolIds) {
|
|
if (toolId.startsWith('direct_server:')) {
|
|
let serverId = toolId.replace('direct_server:', '');
|
|
// Check if serverId is a number
|
|
if (!isNaN(parseInt(serverId))) {
|
|
toolServerIds.push(parseInt(serverId));
|
|
} else {
|
|
toolServerIds.push(serverId);
|
|
}
|
|
} else {
|
|
toolIds.push(toolId);
|
|
}
|
|
}
|
|
|
|
const res = await generateOpenAIChatCompletion(
|
|
localStorage.token,
|
|
{
|
|
stream: stream,
|
|
model: model.id,
|
|
messages: messages,
|
|
params: {
|
|
...$settings?.params,
|
|
...params,
|
|
stop:
|
|
(params?.stop ?? $settings?.params?.stop ?? undefined)
|
|
? (params?.stop.split(',').map((token) => token.trim()) ?? $settings.params.stop).map(
|
|
(str) => decodeURIComponent(JSON.parse('"' + str.replace(/\"/g, '\\"') + '"'))
|
|
)
|
|
: undefined
|
|
},
|
|
|
|
files: (files?.length ?? 0) > 0 ? files : undefined,
|
|
|
|
filter_ids: selectedFilterIds.length > 0 ? selectedFilterIds : undefined,
|
|
tool_ids: toolIds.length > 0 ? toolIds : undefined,
|
|
tool_servers: ($toolServers ?? []).filter(
|
|
(server, idx) => toolServerIds.includes(idx) || toolServerIds.includes(server?.id)
|
|
),
|
|
features: getFeatures(),
|
|
variables: {
|
|
...getPromptVariables($user?.name, $settings?.userLocation ? userLocation : undefined)
|
|
},
|
|
model_item: $models.find((m) => m.id === model.id),
|
|
|
|
session_id: $socket?.id,
|
|
chat_id: $chatId,
|
|
id: responseMessageId,
|
|
|
|
background_tasks: {
|
|
...(!$temporaryChatEnabled &&
|
|
(messages.length == 1 ||
|
|
(messages.length == 2 &&
|
|
messages.at(0)?.role === 'system' &&
|
|
messages.at(1)?.role === 'user')) &&
|
|
(selectedModels[0] === model.id || atSelectedModel !== undefined)
|
|
? {
|
|
title_generation: $settings?.title?.auto ?? true,
|
|
tags_generation: $settings?.autoTags ?? true
|
|
}
|
|
: {}),
|
|
follow_up_generation: $settings?.autoFollowUps ?? true
|
|
},
|
|
|
|
...(stream && (model.info?.meta?.capabilities?.usage ?? false)
|
|
? {
|
|
stream_options: {
|
|
include_usage: true
|
|
}
|
|
}
|
|
: {})
|
|
},
|
|
`${WEBUI_BASE_URL}/api`
|
|
).catch(async (error) => {
|
|
console.log(error);
|
|
|
|
let errorMessage = error;
|
|
if (error?.error?.message) {
|
|
errorMessage = error.error.message;
|
|
} else if (error?.message) {
|
|
errorMessage = error.message;
|
|
}
|
|
|
|
if (typeof errorMessage === 'object') {
|
|
errorMessage = $i18n.t(`Uh-oh! There was an issue with the response.`);
|
|
}
|
|
|
|
toast.error(`${errorMessage}`);
|
|
responseMessage.error = {
|
|
content: error
|
|
};
|
|
|
|
responseMessage.done = true;
|
|
|
|
history.messages[responseMessageId] = responseMessage;
|
|
history.currentId = responseMessageId;
|
|
|
|
return null;
|
|
});
|
|
|
|
if (res) {
|
|
if (res.error) {
|
|
await handleOpenAIError(res.error, responseMessage);
|
|
} else {
|
|
if (taskIds) {
|
|
taskIds.push(res.task_id);
|
|
} else {
|
|
taskIds = [res.task_id];
|
|
}
|
|
}
|
|
}
|
|
|
|
await tick();
|
|
scrollToBottom();
|
|
};
|
|
|
|
const handleOpenAIError = async (error, responseMessage) => {
|
|
let errorMessage = '';
|
|
let innerError;
|
|
|
|
if (error) {
|
|
innerError = error;
|
|
}
|
|
|
|
console.error(innerError);
|
|
if ('detail' in innerError) {
|
|
// FastAPI error
|
|
toast.error(innerError.detail);
|
|
errorMessage = innerError.detail;
|
|
} else if ('error' in innerError) {
|
|
// OpenAI error
|
|
if ('message' in innerError.error) {
|
|
toast.error(innerError.error.message);
|
|
errorMessage = innerError.error.message;
|
|
} else {
|
|
toast.error(innerError.error);
|
|
errorMessage = innerError.error;
|
|
}
|
|
} else if ('message' in innerError) {
|
|
// OpenAI error
|
|
toast.error(innerError.message);
|
|
errorMessage = innerError.message;
|
|
}
|
|
|
|
responseMessage.error = {
|
|
content: $i18n.t(`Uh-oh! There was an issue with the response.`) + '\n' + errorMessage
|
|
};
|
|
responseMessage.done = true;
|
|
|
|
if (responseMessage.statusHistory) {
|
|
responseMessage.statusHistory = responseMessage.statusHistory.filter(
|
|
(status) => status.action !== 'knowledge_search'
|
|
);
|
|
}
|
|
|
|
history.messages[responseMessage.id] = responseMessage;
|
|
};
|
|
|
|
const stopResponse = async () => {
|
|
if (taskIds) {
|
|
for (const taskId of taskIds) {
|
|
const res = await stopTask(localStorage.token, taskId).catch((error) => {
|
|
toast.error(`${error}`);
|
|
return null;
|
|
});
|
|
}
|
|
|
|
taskIds = null;
|
|
|
|
const responseMessage = history.messages[history.currentId];
|
|
// Set all response messages to done
|
|
for (const messageId of history.messages[responseMessage.parentId].childrenIds) {
|
|
history.messages[messageId].done = true;
|
|
}
|
|
|
|
history.messages[history.currentId] = responseMessage;
|
|
|
|
if (autoScroll) {
|
|
scrollToBottom();
|
|
}
|
|
}
|
|
|
|
if (generating) {
|
|
generating = false;
|
|
generationController?.abort();
|
|
generationController = null;
|
|
}
|
|
};
|
|
|
|
const submitMessage = async (parentId, prompt) => {
|
|
let userPrompt = prompt;
|
|
let userMessageId = uuidv4();
|
|
|
|
let userMessage = {
|
|
id: userMessageId,
|
|
parentId: parentId,
|
|
childrenIds: [],
|
|
role: 'user',
|
|
content: userPrompt,
|
|
models: selectedModels,
|
|
timestamp: Math.floor(Date.now() / 1000) // Unix epoch
|
|
};
|
|
|
|
if (parentId !== null) {
|
|
history.messages[parentId].childrenIds = [
|
|
...history.messages[parentId].childrenIds,
|
|
userMessageId
|
|
];
|
|
}
|
|
|
|
history.messages[userMessageId] = userMessage;
|
|
history.currentId = userMessageId;
|
|
|
|
await tick();
|
|
|
|
if (autoScroll) {
|
|
scrollToBottom();
|
|
}
|
|
|
|
await sendMessage(history, userMessageId);
|
|
};
|
|
|
|
const regenerateResponse = async (message, suggestionPrompt = null) => {
|
|
console.log('regenerateResponse');
|
|
|
|
if (history.currentId) {
|
|
let userMessage = history.messages[message.parentId];
|
|
|
|
if (autoScroll) {
|
|
scrollToBottom();
|
|
}
|
|
|
|
await sendMessage(history, userMessage.id, {
|
|
...(suggestionPrompt
|
|
? {
|
|
messages: [
|
|
...createMessagesList(history, message.id),
|
|
{
|
|
role: 'user',
|
|
content: suggestionPrompt
|
|
}
|
|
]
|
|
}
|
|
: {}),
|
|
...((userMessage?.models ?? [...selectedModels]).length > 1
|
|
? {
|
|
// If multiple models are selected, use the model from the message
|
|
modelId: message.model,
|
|
modelIdx: message.modelIdx
|
|
}
|
|
: {})
|
|
});
|
|
}
|
|
};
|
|
|
|
const continueResponse = async () => {
|
|
console.log('continueResponse');
|
|
const _chatId = JSON.parse(JSON.stringify($chatId));
|
|
|
|
if (history.currentId && history.messages[history.currentId].done == true) {
|
|
const responseMessage = history.messages[history.currentId];
|
|
responseMessage.done = false;
|
|
await tick();
|
|
|
|
const model = $models
|
|
.filter((m) => m.id === (responseMessage?.selectedModelId ?? responseMessage.model))
|
|
.at(0);
|
|
|
|
if (model) {
|
|
await sendMessageSocket(
|
|
model,
|
|
createMessagesList(history, responseMessage.id),
|
|
history,
|
|
responseMessage.id,
|
|
_chatId
|
|
);
|
|
}
|
|
}
|
|
};
|
|
|
|
const mergeResponses = async (messageId, responses, _chatId) => {
|
|
console.log('mergeResponses', messageId, responses);
|
|
const message = history.messages[messageId];
|
|
const mergedResponse = {
|
|
status: true,
|
|
content: ''
|
|
};
|
|
message.merged = mergedResponse;
|
|
history.messages[messageId] = message;
|
|
|
|
try {
|
|
generating = true;
|
|
const [res, controller] = await generateMoACompletion(
|
|
localStorage.token,
|
|
message.model,
|
|
history.messages[message.parentId].content,
|
|
responses
|
|
);
|
|
|
|
if (res && res.ok && res.body && generating) {
|
|
generationController = controller;
|
|
const textStream = await createOpenAITextStream(res.body, $settings.splitLargeChunks);
|
|
for await (const update of textStream) {
|
|
const { value, done, sources, error, usage } = update;
|
|
if (error || done) {
|
|
generating = false;
|
|
generationController = null;
|
|
break;
|
|
}
|
|
|
|
if (mergedResponse.content == '' && value == '\n') {
|
|
continue;
|
|
} else {
|
|
mergedResponse.content += value;
|
|
history.messages[messageId] = message;
|
|
}
|
|
|
|
if (autoScroll) {
|
|
scrollToBottom();
|
|
}
|
|
}
|
|
|
|
await saveChatHandler(_chatId, history);
|
|
} else {
|
|
console.error(res);
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
};
|
|
|
|
const initChatHandler = async (history) => {
|
|
let _chatId = $chatId;
|
|
|
|
if (!$temporaryChatEnabled) {
|
|
chat = await createNewChat(
|
|
localStorage.token,
|
|
{
|
|
id: _chatId,
|
|
title: $i18n.t('New Chat'),
|
|
models: selectedModels,
|
|
system: $settings.system ?? undefined,
|
|
params: params,
|
|
history: history,
|
|
messages: createMessagesList(history, history.currentId),
|
|
tags: [],
|
|
timestamp: Date.now()
|
|
},
|
|
$selectedFolder?.id
|
|
);
|
|
|
|
_chatId = chat.id;
|
|
await chatId.set(_chatId);
|
|
|
|
window.history.replaceState(history.state, '', `/c/${_chatId}`);
|
|
|
|
await tick();
|
|
|
|
await chats.set(await getChatList(localStorage.token, $currentChatPage));
|
|
currentChatPage.set(1);
|
|
|
|
selectedFolder.set(null);
|
|
} else {
|
|
_chatId = `local:${$socket?.id}`; // Use socket id for temporary chat
|
|
await chatId.set(_chatId);
|
|
}
|
|
await tick();
|
|
|
|
return _chatId;
|
|
};
|
|
|
|
const saveChatHandler = async (_chatId, history) => {
|
|
if ($chatId == _chatId) {
|
|
if (!$temporaryChatEnabled) {
|
|
chat = await updateChatById(localStorage.token, _chatId, {
|
|
models: selectedModels,
|
|
history: history,
|
|
messages: createMessagesList(history, history.currentId),
|
|
params: params,
|
|
files: chatFiles
|
|
});
|
|
currentChatPage.set(1);
|
|
await chats.set(await getChatList(localStorage.token, $currentChatPage));
|
|
}
|
|
}
|
|
};
|
|
|
|
const MAX_DRAFT_LENGTH = 5000;
|
|
let saveDraftTimeout = null;
|
|
|
|
const saveDraft = async (draft, chatId = null) => {
|
|
if (saveDraftTimeout) {
|
|
clearTimeout(saveDraftTimeout);
|
|
}
|
|
|
|
if (draft.prompt !== null && draft.prompt.length < MAX_DRAFT_LENGTH) {
|
|
saveDraftTimeout = setTimeout(async () => {
|
|
await sessionStorage.setItem(
|
|
`chat-input${chatId ? `-${chatId}` : ''}`,
|
|
JSON.stringify(draft)
|
|
);
|
|
}, 500);
|
|
} else {
|
|
sessionStorage.removeItem(`chat-input${chatId ? `-${chatId}` : ''}`);
|
|
}
|
|
};
|
|
|
|
const clearDraft = async (chatId = null) => {
|
|
if (saveDraftTimeout) {
|
|
clearTimeout(saveDraftTimeout);
|
|
}
|
|
await sessionStorage.removeItem(`chat-input${chatId ? `-${chatId}` : ''}`);
|
|
};
|
|
|
|
const moveChatHandler = async (chatId, folderId) => {
|
|
if (chatId && folderId) {
|
|
const res = await updateChatFolderIdById(localStorage.token, chatId, folderId).catch(
|
|
(error) => {
|
|
toast.error(`${error}`);
|
|
return null;
|
|
}
|
|
);
|
|
|
|
if (res) {
|
|
currentChatPage.set(1);
|
|
await chats.set(await getChatList(localStorage.token, $currentChatPage));
|
|
await pinnedChats.set(await getPinnedChatList(localStorage.token));
|
|
|
|
toast.success($i18n.t('Chat moved successfully'));
|
|
}
|
|
} else {
|
|
toast.error($i18n.t('Failed to move chat'));
|
|
}
|
|
};
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<title>
|
|
{$settings.showChatTitleInTab !== false && $chatTitle
|
|
? `${$chatTitle.length > 30 ? `${$chatTitle.slice(0, 30)}...` : $chatTitle} • ${$WEBUI_NAME}`
|
|
: `${$WEBUI_NAME}`}
|
|
</title>
|
|
</svelte:head>
|
|
|
|
<audio id="audioElement" src="" style="display: none;" />
|
|
|
|
<EventConfirmDialog
|
|
bind:show={showEventConfirmation}
|
|
title={eventConfirmationTitle}
|
|
message={eventConfirmationMessage}
|
|
input={eventConfirmationInput}
|
|
inputPlaceholder={eventConfirmationInputPlaceholder}
|
|
inputValue={eventConfirmationInputValue}
|
|
on:confirm={(e) => {
|
|
if (e.detail) {
|
|
eventCallback(e.detail);
|
|
} else {
|
|
eventCallback(true);
|
|
}
|
|
}}
|
|
on:cancel={() => {
|
|
eventCallback(false);
|
|
}}
|
|
/>
|
|
|
|
<div
|
|
class="h-screen max-h-[100dvh] transition-width duration-200 ease-in-out {$showSidebar
|
|
? ' md:max-w-[calc(100%-260px)]'
|
|
: ' '} w-full max-w-full flex flex-col"
|
|
id="chat-container"
|
|
>
|
|
{#if !loading}
|
|
<div in:fade={{ duration: 50 }} class="w-full h-full flex flex-col">
|
|
{#if $selectedFolder && $selectedFolder?.meta?.background_image_url}
|
|
<div
|
|
class="absolute {$showSidebar
|
|
? 'md:max-w-[calc(100%-260px)] md:translate-x-[260px]'
|
|
: ''} top-0 left-0 w-full h-full bg-cover bg-center bg-no-repeat"
|
|
style="background-image: url({$selectedFolder?.meta?.background_image_url}) "
|
|
/>
|
|
|
|
<div
|
|
class="absolute top-0 left-0 w-full h-full bg-linear-to-t from-white to-white/85 dark:from-gray-900 dark:to-gray-900/90 z-0"
|
|
/>
|
|
{:else if $settings?.backgroundImageUrl ?? $config?.license_metadata?.background_image_url ?? null}
|
|
<div
|
|
class="absolute {$showSidebar
|
|
? 'md:max-w-[calc(100%-260px)] md:translate-x-[260px]'
|
|
: ''} top-0 left-0 w-full h-full bg-cover bg-center bg-no-repeat"
|
|
style="background-image: url({$settings?.backgroundImageUrl ??
|
|
$config?.license_metadata?.background_image_url}) "
|
|
/>
|
|
|
|
<div
|
|
class="absolute top-0 left-0 w-full h-full bg-linear-to-t from-white to-white/85 dark:from-gray-900 dark:to-gray-900/90 z-0"
|
|
/>
|
|
{/if}
|
|
|
|
<PaneGroup direction="horizontal" class="w-full h-full">
|
|
<Pane defaultSize={50} minSize={30} class="h-full flex relative max-w-full flex-col">
|
|
<Navbar
|
|
bind:this={navbarElement}
|
|
chat={{
|
|
id: $chatId,
|
|
chat: {
|
|
title: $chatTitle,
|
|
models: selectedModels,
|
|
system: $settings.system ?? undefined,
|
|
params: params,
|
|
history: history,
|
|
timestamp: Date.now()
|
|
}
|
|
}}
|
|
{history}
|
|
title={$chatTitle}
|
|
bind:selectedModels
|
|
shareEnabled={!!history.currentId}
|
|
{initNewChat}
|
|
archiveChatHandler={() => {}}
|
|
{moveChatHandler}
|
|
onSaveTempChat={async () => {
|
|
try {
|
|
if (!history?.currentId || !Object.keys(history.messages).length) {
|
|
toast.error($i18n.t('No conversation to save'));
|
|
return;
|
|
}
|
|
const messages = createMessagesList(history, history.currentId);
|
|
const title =
|
|
messages.find((m) => m.role === 'user')?.content ?? $i18n.t('New Chat');
|
|
|
|
const savedChat = await createNewChat(
|
|
localStorage.token,
|
|
{
|
|
id: uuidv4(),
|
|
title: title.length > 50 ? `${title.slice(0, 50)}...` : title,
|
|
models: selectedModels,
|
|
history: history,
|
|
messages: messages,
|
|
timestamp: Date.now()
|
|
},
|
|
null
|
|
);
|
|
|
|
if (savedChat) {
|
|
temporaryChatEnabled.set(false);
|
|
chatId.set(savedChat.id);
|
|
chats.set(await getChatList(localStorage.token, $currentChatPage));
|
|
|
|
await goto(`/c/${savedChat.id}`);
|
|
toast.success($i18n.t('Conversation saved successfully'));
|
|
}
|
|
} catch (error) {
|
|
console.error('Error saving conversation:', error);
|
|
toast.error($i18n.t('Failed to save conversation'));
|
|
}
|
|
}}
|
|
/>
|
|
|
|
<div class="flex flex-col flex-auto z-10 w-full @container overflow-auto">
|
|
{#if ($settings?.landingPageMode === 'chat' && !$selectedFolder) || createMessagesList(history, history.currentId).length > 0}
|
|
<div
|
|
class=" pb-2.5 flex flex-col justify-between w-full flex-auto overflow-auto h-0 max-w-full z-10 scrollbar-hidden"
|
|
id="messages-container"
|
|
bind:this={messagesContainerElement}
|
|
on:scroll={(e) => {
|
|
autoScroll =
|
|
messagesContainerElement.scrollHeight - messagesContainerElement.scrollTop <=
|
|
messagesContainerElement.clientHeight + 5;
|
|
}}
|
|
>
|
|
<div class=" h-full w-full flex flex-col">
|
|
<Messages
|
|
chatId={$chatId}
|
|
bind:history
|
|
bind:autoScroll
|
|
bind:prompt
|
|
setInputText={(text) => {
|
|
messageInput?.setText(text);
|
|
}}
|
|
{selectedModels}
|
|
{atSelectedModel}
|
|
{sendMessage}
|
|
{showMessage}
|
|
{submitMessage}
|
|
{continueResponse}
|
|
{regenerateResponse}
|
|
{mergeResponses}
|
|
{chatActionHandler}
|
|
{addMessages}
|
|
topPadding={true}
|
|
bottomPadding={files.length > 0}
|
|
{onSelect}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div class=" pb-2">
|
|
<MessageInput
|
|
bind:this={messageInput}
|
|
{history}
|
|
{taskIds}
|
|
{selectedModels}
|
|
bind:files
|
|
bind:prompt
|
|
bind:autoScroll
|
|
bind:selectedToolIds
|
|
bind:selectedFilterIds
|
|
bind:imageGenerationEnabled
|
|
bind:codeInterpreterEnabled
|
|
bind:webSearchEnabled
|
|
bind:atSelectedModel
|
|
bind:showCommands
|
|
toolServers={$toolServers}
|
|
{generating}
|
|
{stopResponse}
|
|
{createMessagePair}
|
|
onChange={(data) => {
|
|
if (!$temporaryChatEnabled) {
|
|
saveDraft(data, $chatId);
|
|
}
|
|
}}
|
|
on:upload={async (e) => {
|
|
const { type, data } = e.detail;
|
|
|
|
if (type === 'web') {
|
|
await uploadWeb(data);
|
|
} else if (type === 'youtube') {
|
|
await uploadYoutubeTranscription(data);
|
|
} else if (type === 'google-drive') {
|
|
await uploadGoogleDriveFile(data);
|
|
}
|
|
}}
|
|
on:submit={async (e) => {
|
|
clearDraft();
|
|
if (e.detail || files.length > 0) {
|
|
await tick();
|
|
|
|
submitPrompt(e.detail.replaceAll('\n\n', '\n'));
|
|
}
|
|
}}
|
|
/>
|
|
|
|
<div
|
|
class="absolute bottom-1 text-xs text-gray-500 text-center line-clamp-1 right-0 left-0"
|
|
>
|
|
<!-- {$i18n.t('LLMs can make mistakes. Verify important information.')} -->
|
|
</div>
|
|
</div>
|
|
{:else}
|
|
<div class="flex items-center h-full">
|
|
<Placeholder
|
|
{history}
|
|
{selectedModels}
|
|
bind:messageInput
|
|
bind:files
|
|
bind:prompt
|
|
bind:autoScroll
|
|
bind:selectedToolIds
|
|
bind:selectedFilterIds
|
|
bind:imageGenerationEnabled
|
|
bind:codeInterpreterEnabled
|
|
bind:webSearchEnabled
|
|
bind:atSelectedModel
|
|
bind:showCommands
|
|
toolServers={$toolServers}
|
|
{stopResponse}
|
|
{createMessagePair}
|
|
{onSelect}
|
|
onChange={(data) => {
|
|
if (!$temporaryChatEnabled) {
|
|
saveDraft(data);
|
|
}
|
|
}}
|
|
on:upload={async (e) => {
|
|
const { type, data } = e.detail;
|
|
|
|
if (type === 'web') {
|
|
await uploadWeb(data);
|
|
} else if (type === 'youtube') {
|
|
await uploadYoutubeTranscription(data);
|
|
}
|
|
}}
|
|
on:submit={async (e) => {
|
|
clearDraft();
|
|
if (e.detail || files.length > 0) {
|
|
await tick();
|
|
submitPrompt(e.detail.replaceAll('\n\n', '\n'));
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</Pane>
|
|
|
|
<ChatControls
|
|
bind:this={controlPaneComponent}
|
|
bind:history
|
|
bind:chatFiles
|
|
bind:params
|
|
bind:files
|
|
bind:pane={controlPane}
|
|
chatId={$chatId}
|
|
modelId={selectedModelIds?.at(0) ?? null}
|
|
models={selectedModelIds.reduce((a, e, i, arr) => {
|
|
const model = $models.find((m) => m.id === e);
|
|
if (model) {
|
|
return [...a, model];
|
|
}
|
|
return a;
|
|
}, [])}
|
|
{submitPrompt}
|
|
{stopResponse}
|
|
{showMessage}
|
|
{eventTarget}
|
|
/>
|
|
</PaneGroup>
|
|
</div>
|
|
{:else if loading}
|
|
<div class=" flex items-center justify-center h-full w-full">
|
|
<div class="m-auto">
|
|
<Spinner className="size-5" />
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|