This commit is contained in:
Timothy Jaeryang Baek 2026-10-25 05:47:26 +09:00
parent 5e62c8997f
commit 049d75e176
4 changed files with 157 additions and 107 deletions

View file

@ -2389,7 +2389,7 @@
if (!$chatId) {
const modelId = selectedModelIds[0];
const _chatId = await initChatHandler(history);
if (!$temporaryChatEnabled) {
if (_chatId && !$temporaryChatEnabled) {
// Realtime replies need no chat completion; start metadata work at chat creation.
void generateVoiceChatMetadata(_chatId, modelId, content, chat.title).catch((error) => {
console.error('Could not save voice chat metadata', error);
@ -3793,6 +3793,37 @@
prompt = '';
};
const getChatId = async (_history: typeof history) => {
let _chatId = $chatId;
// Empty embedded drafts create their backing chat only when the first message is sent.
if (!_chatId) {
if (embedded && onCreateEmbeddedChat) {
const createdChat = await onCreateEmbeddedChat();
if (!createdChat?.id) {
toast.error($i18n.t('Failed to create chat'));
return null;
}
if (history !== _history) return null;
chat = createdChat;
_chatId = createdChat.id;
loadedChatIdProp = _chatId;
await chatId.set(_chatId);
await chatTitle.set(createdChat?.chat?.title ?? createdChat?.title ?? $i18n.t('Chat'));
params = structuredClone(createdChat?.chat?.params ?? {});
delete params.note_id;
chatFiles = mergeFiles(chatFiles, createdChat?.chat?.files ?? []);
await onSelectEmbeddedChat?.(_chatId);
} else if ($temporaryChatEnabled) {
_chatId = createTemporaryChatId($socket?.id);
await chatId.set(_chatId);
}
await tick();
}
return _chatId;
};
const submitHandler = async (
userPrompt: string,
{
@ -3800,6 +3831,7 @@
bridge: bridgeRequest = null
}: { _raw?: boolean; bridge?: { userMessageId: string; modelId: string } | null } = {}
): Promise<BridgeSubmission> => {
if (processing === 'uploading') return { status: 'deferred' };
emitTyping(false);
if (bridgeRequest && (selectedModelIds.length !== 1 || !bridgeRequest.modelId))
return { status: 'rejected' };
@ -3880,6 +3912,26 @@
return { status: 'deferred' };
}
const currentHistory = history;
processing = 'uploading';
try {
const _chatId = await getChatId(currentHistory);
if (_chatId === null) return { status: 'rejected' };
if (history !== currentHistory) return { status: 'rejected' };
if (
files.some((file) => file.status === 'pending' && file.file instanceof File) &&
!(await messageInput?.uploadPendingFiles(_chatId || (await initChatHandler(history))))
)
return { status: 'rejected' };
if (history !== currentHistory) return { status: 'rejected' };
} catch (error) {
toast.error(`${error}`);
return { status: 'rejected' };
} finally {
processing = '';
}
if (
($chatRequestQueues[$chatId] ?? []).some((m) =>
(m.files ?? []).some((file) => ['uploading', 'error'].includes(file.status))
@ -4036,32 +4088,6 @@
}
history = history;
// Empty embedded drafts create their backing chat only when the first message is sent.
if (!_chatId) {
if (embedded && onCreateEmbeddedChat) {
const createdChat = await onCreateEmbeddedChat();
if (!createdChat?.id) {
toast.error($i18n.t('Failed to create chat'));
return;
}
chat = createdChat;
_chatId = createdChat.id;
loadedChatIdProp = _chatId;
await chatId.set(_chatId);
await chatTitle.set(createdChat?.chat?.title ?? createdChat?.title ?? $i18n.t('Chat'));
params = structuredClone(createdChat?.chat?.params ?? {});
delete params.note_id;
chatFiles = mergeFiles(chatFiles, createdChat?.chat?.files ?? []);
await onSelectEmbeddedChat?.(_chatId);
} else if ($temporaryChatEnabled) {
_chatId = createTemporaryChatId($socket?.id);
await chatId.set(_chatId);
}
await tick();
}
await tick();
// Re-clone history so sendMessageSocket gets the response messages we just added
@ -4405,8 +4431,7 @@
background_tasks: {
...(!$temporaryChatEnabled &&
(!_chatId ||
(embedded &&
(userMessage?.parentId ?? null) === null &&
((userMessage?.parentId ?? null) === null &&
createMessagesList(_history, responseMessageId).length === 2))
? {
title_generation: $settings?.title?.auto ?? true,
@ -4733,12 +4758,12 @@
}
};
const initChatHandler = async (history) => {
const initChatHandler = async (_history: typeof history) => {
let _chatId = $chatId;
const selectedFolderId = $selectedFolder?.id;
if (!$temporaryChatEnabled) {
chat = await createNewChat(
const createdChat = await createNewChat(
localStorage.token,
{
id: _chatId,
@ -4746,8 +4771,8 @@
models: selectedModels,
system: $settings.system ?? undefined,
params: params,
history: history,
messages: createMessagesList(history, history.currentId),
history: _history,
messages: createMessagesList(_history, _history.currentId),
tags: [],
timestamp: Date.now()
},
@ -4755,6 +4780,9 @@
chatVariables
);
if (!createdChat?.id) throw new Error($i18n.t('Failed to create chat'));
if (history !== _history || $chatId !== _chatId) return '';
chat = createdChat;
_chatId = chat.id;
await chatId.set(_chatId);
@ -4828,7 +4856,7 @@
const getChatInputDraft = () => ({
prompt,
files: files
.filter((file) => file.type !== 'image')
.filter((file) => file.type !== 'image' && !(file.file instanceof File))
.map((file) => ({
...file,
user: undefined,
@ -5475,6 +5503,7 @@
{:else}
<div class="flex items-center h-full">
<Placeholder
chatId={$chatId}
{terminalContext}
callActive={!!(bridge?.connected || bridge?.connecting)}
callEnabled={!isShared}

View file

@ -65,6 +65,7 @@
} from '$lib/utils';
import { uploadFile } from '$lib/apis/files';
import { getCwd, uploadNewFileToTerminal } from '$lib/apis/terminal';
import { isSavedChatId } from '$lib/utils/chatId';
import { generateAutoCompletion } from '$lib/apis';
import { deleteFileById } from '$lib/apis/files';
import { getChatById } from '$lib/apis/chats';
@ -161,7 +162,6 @@
export let autoScroll = false;
export let generating = false;
export let uploadPending = false;
export let atSelectedModel: Model | undefined = undefined;
export let selectedModels: [''];
@ -258,7 +258,7 @@
$: chatInputDraft = {
prompt,
files: files
.filter((file) => file.type !== 'image')
.filter((file) => file.type !== 'image' && !(file.file instanceof File))
.map((file) => {
return {
...file,
@ -980,6 +980,67 @@
);
};
const uploadTerminalFile = async (fileItem, sessionId: string | null) => {
fileItem.status = 'uploading';
fileItem.error = '';
files = files;
try {
const terminal = getFilesystemUploadTerminal(fileItem.terminal_id);
if (!terminal) throw new Error('The attached file’s terminal is unavailable.');
if (fileItem.session_id && fileItem.session_id !== sessionId)
throw new Error('Attached files must belong to this chat.');
if (terminal.contexts?.chat?.context_id === 'chat_id' && !isSavedChatId(sessionId))
throw new Error('A saved chat is required for this terminal');
const cwd =
(terminalContext?.terminalId === fileItem.terminal_id
? terminalContext?.currentDirectory
: null) || (await getCwd(terminal.url, terminal.key, sessionId || undefined))?.cwd;
if (!cwd) throw new Error($i18n.t('Could not determine the upload folder.'));
const uploadedFile = await uploadNewFileToTerminal(
terminal.url,
terminal.key,
cwd,
fileItem.file,
sessionId || undefined
);
if (!uploadedFile) throw new Error($i18n.t('Failed to upload file.'));
fileItem.status = 'uploaded';
fileItem.session_id = sessionId;
fileItem.name = uploadedFile.path.split('/').pop() || fileItem.name;
fileItem.id = uploadedFile.path;
fileItem.path = uploadedFile.path;
fileItem.url = uploadedFile.path;
fileItem.size = uploadedFile.size ?? fileItem.size;
fileItem.file = uploadedFile;
showFileNavDir.set(uploadedFile.path);
} catch (error) {
fileItem.status = 'error';
fileItem.error = `${error}`;
toast.error(fileItem.error);
return false;
} finally {
files = files;
}
return true;
};
export const uploadPendingFiles = async (sessionId: string) => {
if (!sessionId) return false;
for (const fileItem of files.filter(
(item) => item.status === 'pending' && item.file instanceof File
)) {
if (chatId !== sessionId) return false;
if (!(await uploadTerminalFile(fileItem, sessionId))) {
fileItem.status = 'pending';
files = files;
return false;
}
}
return !files.some((item) => item.status === 'pending' && item.file instanceof File);
};
const uploadFileHandler = async (file, process = true, itemData = {}) => {
if ($_user?.role !== 'admin' && !($_user?.permissions?.chat?.file_upload ?? true)) {
toast.error($i18n.t('You do not have permission to upload files.'));
@ -1019,54 +1080,20 @@
files = [...files, fileItem];
if (filesystemUploadTerminal) {
fileItem.type = 'filesystem';
fileItem.file = file;
fileItem.terminal_id = $selectedTerminalId;
fileItem.session_id = chatId || null;
try {
const cwd =
(terminalContext?.terminalId === $selectedTerminalId
? terminalContext?.currentDirectory
: null) ||
(
await getCwd(
filesystemUploadTerminal.url,
filesystemUploadTerminal.key,
fileItem.session_id || undefined
)
)?.cwd;
if (!cwd) throw new Error($i18n.t('Could not determine the upload folder.'));
const uploadedFile = await uploadNewFileToTerminal(
filesystemUploadTerminal.url,
filesystemUploadTerminal.key,
cwd,
file,
fileItem.session_id || undefined
);
if (uploadedFile) {
fileItem.type = 'filesystem';
fileItem.status = 'uploaded';
fileItem.name = uploadedFile.path.split('/').pop() || file.name;
fileItem.id = uploadedFile.path;
fileItem.path = uploadedFile.path;
fileItem.url = uploadedFile.path;
fileItem.size = uploadedFile.size ?? file.size;
fileItem.file = uploadedFile;
files = files;
showFileNavDir.set(uploadedFile.path);
} else {
fileItem.status = 'error';
fileItem.error = $i18n.t('Failed to upload file.');
toast.error(fileItem.error);
files = files.filter((item) => item?.itemId !== tempItemId);
}
} catch (e) {
fileItem.status = 'error';
fileItem.error = `${e}`;
toast.error(`${e}`);
files = files.filter((item) => item?.itemId !== tempItemId);
} finally {
onUpdate({ file: fileItem });
if (!chatId && filesystemUploadTerminal.contexts?.chat?.context_id === 'chat_id') {
fileItem.status = 'pending';
files = files;
return;
}
if (!(await uploadTerminalFile(fileItem, fileItem.session_id))) {
files = files.filter((item) => item?.itemId !== tempItemId);
}
onUpdate({ file: fileItem });
return;
}
@ -2883,38 +2910,28 @@
</div>
{:else}
<div class=" flex items-center">
<Tooltip
content={uploadPending
? $i18n.t('Waiting for upload...')
: $i18n.t('Send message')}
>
<Tooltip content={$i18n.t('Send message')}>
<button
id="send-message-button"
aria-label={uploadPending
? $i18n.t('Waiting for upload...')
: $i18n.t('Send message')}
class="{!(prompt === '' && files.length === 0) || uploadPending
aria-label={$i18n.t('Send message')}
class="{!(prompt === '' && files.length === 0)
? 'bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100 '
: 'text-white bg-gray-200 dark:text-gray-900 dark:bg-gray-700 disabled'} transition rounded-full p-[0.3125rem] self-center"
type="submit"
disabled={(prompt === '' && files.length === 0) || uploadPending}
disabled={prompt === '' && files.length === 0}
>
{#if uploadPending}
<Spinner className="size-5" />
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="size-5"
>
<path
fill-rule="evenodd"
d="M8 14a.75.75 0 0 1-.75-.75V4.56L4.03 7.78a.75.75 0 0 1-1.06-1.06l4.5-4.5a.75.75 0 0 1 1.06 0l4.5 4.5a.75.75 0 0 1-1.06 1.06L8.75 4.56v8.69A.75.75 0 0 1 8 14Z"
clip-rule="evenodd"
/>
</svg>
{/if}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
fill="currentColor"
class="size-5"
>
<path
fill-rule="evenodd"
d="M8 14a.75.75 0 0 1-.75-.75V4.56L4.03 7.78a.75.75 0 0 1-1.06-1.06l4.5-4.5a.75.75 0 0 1 1.06 0l4.5 4.5a.75.75 0 0 1-1.06 1.06L8.75 4.56v8.69A.75.75 0 0 1 8 14Z"
clip-rule="evenodd"
/>
</svg>
</button>
</Tooltip>
</div>

View file

@ -48,6 +48,7 @@
export let selectedModels: [''];
export let history;
export let chatId = '';
export let prompt = '';
export let files = [];
@ -252,6 +253,7 @@
<div class="text-base font-normal @md:max-w-3xl w-full py-3 {atSelectedModel ? 'mt-2' : ''}">
{#if !($selectedFolder && folderReadOnly)}
<MessageInput
{chatId}
{terminalContext}
{callActive}
{callEnabled}

View file

@ -2524,6 +2524,8 @@
"settings.admin.authentication.clientId.label": "Client ID",
"settings.admin.authentication.clientSecret.description": "OAuth client secret from the provider.",
"settings.admin.authentication.clientSecret.label": "Client Secret",
"settings.admin.authentication.defaultEmailDomain.description": "Uses username@domain when the LDAP email attribute is empty. Leave empty to disable. Signs in to an existing account with that email; use a domain you control.",
"settings.admin.authentication.defaultEmailDomain.label": "Default Email Domain",
"settings.admin.authentication.defaultGroup.description": "Group assigned to new users by default.",
"settings.admin.authentication.defaultGroup.label": "Default Group",
"settings.admin.authentication.defaultUserRole.description": "Role assigned to new users when they create an account.",