Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 88 additions & 6 deletions src/lib/components/chat/ChatMessage.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script lang="ts">
import type { Message } from "$lib/types/Message";
import type { Message, MessageFile } from "$lib/types/Message";
import { tick } from "svelte";

import { usePublicConfig } from "$lib/utils/PublicConfig.svelte";
Expand All @@ -13,6 +13,7 @@
import UploadedFile from "./UploadedFile.svelte";

import MarkdownRenderer from "./MarkdownRenderer.svelte";
import Modal from "../Modal.svelte";
import OpenReasoningResults from "./OpenReasoningResults.svelte";
import Alternatives from "./Alternatives.svelte";
import MessageAvatar from "./MessageAvatar.svelte";
Expand All @@ -31,8 +32,9 @@
alternatives?: Message["id"][];
editMsdgId?: Message["id"] | null;
isLast?: boolean;
onretry?: (payload: { id: Message["id"]; content?: string }) => void;
onretry?: (payload: { id: Message["id"]; content?: string; files?: MessageFile[] }) => void;
onshowAlternateMsg?: (payload: { id: Message["id"] }) => void;
onfileedit?: (payload: { messageId: string; files: MessageFile[] }) => void;
}

let {
Expand All @@ -46,13 +48,58 @@
isLast = false,
onretry,
onshowAlternateMsg,
onfileedit,
}: Props = $props();

let contentEl: HTMLElement | undefined = $state();
let isCopied = $state(false);
let messageWidth: number = $state(0);
let messageInfoWidth: number = $state(0);

let editingFileIndex: number | null = $state(null);
let editingFileText = $state("");
let editFileTextarea: HTMLTextAreaElement | undefined = $state();

function startFileEdit(index: number, text: string) {
editingFileIndex = index;
editingFileText = text;
}

function toBase64Utf8(str: string) {
return btoa(
new TextEncoder().encode(str).reduce((data, byte) => data + String.fromCharCode(byte), "")
);
}

function saveFileEdit() {
if (editingFileIndex === null) return;
if (!message.files) return;

const updated = {
...message.files[editingFileIndex],
value: toBase64Utf8(editingFileText),
type: "base64" as const,
};

const newFiles = message.files.map((f, j) => (j === editingFileIndex ? updated : f));

onfileedit?.({ messageId: message.id, files: newFiles });
onretry?.({ id: message.id, content: message.content, files: newFiles });
editingFileIndex = null;
}

function autoResize(el: HTMLTextAreaElement) {
el.style.height = "auto";
el.style.height = Math.min(el.scrollHeight, window.innerHeight * 0.65) + "px";
}

$effect(() => {
if (editingFileIndex !== null) {
tick().then(() => {
if (editFileTextarea) autoResize(editFileTextarea);
});
}
});
$effect(() => {
// referenced to appease linter for currently-unused props
void _isAuthor;
Expand Down Expand Up @@ -251,8 +298,8 @@
>
{#if message.files?.length}
<div class="flex h-fit flex-wrap gap-x-5 gap-y-2">
{#each message.files as file (file.value)}
<UploadedFile {file} canClose={false} />
{#each message.files as file, i (file.value)}
<UploadedFile {file} canClose={false} onedit={(text) => startFileEdit(i, text)} />
{/each}
</div>
{/if}
Expand Down Expand Up @@ -406,8 +453,8 @@
<div class="flex w-full flex-col gap-2">
{#if message.files?.length}
<div class="flex w-fit gap-4 px-5">
{#each message.files as file}
<UploadedFile {file} canClose={false} />
{#each message.files as file, i}
<UploadedFile {file} canClose={false} onedit={(text) => startFileEdit(i, text)} />
{/each}
</div>
{/if}
Expand Down Expand Up @@ -490,6 +537,41 @@
</div>
{/if}

{#if editingFileIndex !== null}
<Modal width="max-w-5xl w-[90vw]" onclose={() => (editingFileIndex = null)}>
<div class="relative flex max-h-[95vh] flex-col gap-3 p-4">
<h3 class="text-lg font-semibold">Edit file</h3>

<textarea
bind:this={editFileTextarea}
bind:value={editingFileText}
oninput={(e) => autoResize(e.currentTarget)}
class="max-h-[95vh] min-h-[120px] resize-none overflow-y-auto rounded border p-2 text-sm dark:bg-gray-900"
></textarea>

<div class="flex w-full flex-row flex-nowrap items-center justify-center gap-2 pt-2">
<button
type="submit"
class="btn rounded-lg bg-gray-200 px-3 py-1.5
text-sm text-gray-600 hover:text-gray-800 focus:ring-0
dark:bg-gray-700 dark:text-gray-300 dark:hover:text-gray-100"
onclick={saveFileEdit}
>
Send
</button>
<button
type="button"
class="btn rounded-sm p-2 text-sm text-gray-400 hover:text-gray-500 focus:ring-0
dark:text-gray-400 dark:hover:text-gray-300"
onclick={() => (editingFileIndex = null)}
>
Cancel
</button>
</div>
</div>
</Modal>
{/if}

<style>
@keyframes loading {
to {
Expand Down
77 changes: 75 additions & 2 deletions src/lib/components/chat/ChatWindow.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script lang="ts">
import type { Message, MessageFile } from "$lib/types/Message";
import { onDestroy } from "svelte";
import { onDestroy, tick } from "svelte";

import IconOmni from "$lib/components/icons/IconOmni.svelte";
import CarbonCaretDown from "~icons/carbon/caret-down";
Expand All @@ -12,6 +12,7 @@
import VoiceRecorder from "./VoiceRecorder.svelte";
import StopGeneratingBtn from "../StopGeneratingBtn.svelte";
import type { Model } from "$lib/types/Model";
import Modal from "../Modal.svelte";
import FileDropzone from "./FileDropzone.svelte";
import RetryBtn from "../RetryBtn.svelte";
import file2base64 from "$lib/utils/file2base64";
Expand Down Expand Up @@ -60,7 +61,7 @@
files?: File[];
onmessage?: (content: string) => void;
onstop?: () => void;
onretry?: (payload: { id: Message["id"]; content?: string }) => void;
onretry?: (payload: { id: Message["id"]; content?: string; files?: MessageFile[] }) => void;
onshowAlternateMsg?: (payload: { id: Message["id"] }) => void;
draft?: string;
}
Expand Down Expand Up @@ -334,6 +335,10 @@
!loading
);

let editingDraftFileIndex: number | null = $state(null);
let editingDraftFileText = $state("");
let editDraftFileTextarea: HTMLTextAreaElement | undefined = $state();

$effect(() => {
if (!currentModel.isRouter || !messages.length) {
activeRouterExamplePrompt = null;
Expand All @@ -356,6 +361,32 @@
handleSubmit();
}

function autoResize(el: HTMLTextAreaElement) {
el.style.height = "auto";
el.style.height = Math.min(el.scrollHeight, window.innerHeight * 0.65) + "px";
}

function saveDraftFileEdit() {
if (editingDraftFileIndex === null) return;

const old = files[editingDraftFileIndex];

const updated = new File([editingDraftFileText], old.name, {
type: old.type,
});

files = files.map((f, i) => (i === editingDraftFileIndex ? updated : f));

editingDraftFileIndex = null;
}

$effect(() => {
if (editingDraftFileIndex !== null) {
tick().then(() => {
if (editDraftFileTextarea) autoResize(editDraftFileTextarea);
});
}
});
async function startExample(example: RouterExample) {
if (requireAuthUser()) return;
activeRouterExamplePrompt = example.prompt;
Expand Down Expand Up @@ -493,6 +524,9 @@
bind:editMsdgId
onretry={(payload) => onretry?.(payload)}
onshowAlternateMsg={(payload) => onshowAlternateMsg?.(payload)}
onfileedit={({ messageId, files }) => {
messages = messages.map((m) => (m.id === messageId ? { ...m, files } : m));
}}
/>
{/each}
{#if isReadOnly}
Expand Down Expand Up @@ -573,6 +607,10 @@
onclose={() => {
files = files.filter((_, i) => i !== index);
}}
onedit={(text) => {
editingDraftFileIndex = index;
editingDraftFileText = text;
}}
/>
{/await}
{/each}
Expand Down Expand Up @@ -750,6 +788,41 @@
</div>
</div>

{#if editingDraftFileIndex !== null}
<Modal width="max-w-5xl w-[90vw]" onclose={() => (editingDraftFileIndex = null)}>
<div class="relative flex max-h-[95vh] flex-col gap-3 p-4">
<h3 class="text-lg font-semibold">Edit file</h3>

<textarea
bind:this={editDraftFileTextarea}
bind:value={editingDraftFileText}
oninput={(e) => autoResize(e.currentTarget)}
class="max-h-[95vh] min-h-[120px] resize-none overflow-y-auto rounded border p-2 text-sm dark:bg-gray-900"
></textarea>

<div class="flex w-full flex-row flex-nowrap items-center justify-center gap-2 pt-2">
<button
type="button"
class="btn rounded-lg bg-gray-200 px-3 py-1.5
text-sm text-gray-600 hover:text-gray-800 focus:ring-0
dark:bg-gray-700 dark:text-gray-300 dark:hover:text-gray-100"
onclick={saveDraftFileEdit}
>
Save
</button>
<button
type="button"
class="btn rounded-sm p-2 text-sm text-gray-400 hover:text-gray-500 focus:ring-0
dark:text-gray-400 dark:hover:text-gray-300"
onclick={() => (editingDraftFileIndex = null)}
>
Cancel
</button>
</div>
</div>
</Modal>
{/if}

<style lang="postcss">
.paste-glow {
animation: glow 1s cubic-bezier(0.4, 0, 0.2, 1) forwards;
Expand Down
51 changes: 47 additions & 4 deletions src/lib/components/chat/UploadedFile.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@
import { page } from "$app/state";
import type { MessageFile } from "$lib/types/Message";
import CarbonClose from "~icons/carbon/close";
import CarbonPen from "~icons/carbon/pen";
import CarbonDocumentBlank from "~icons/carbon/document-blank";
import CarbonDownload from "~icons/carbon/download";
import CarbonDocument from "~icons/carbon/document";
import CopyToClipBoardBtn from "../CopyToClipBoardBtn.svelte";
import Modal from "../Modal.svelte";
import AudioPlayer from "../players/AudioPlayer.svelte";
import EosIconsLoading from "~icons/eos-icons/loading";
Expand All @@ -15,9 +17,10 @@
file: MessageFile;
canClose?: boolean;
onclose?: () => void;
onedit?: (text: string) => void;
}

let { file, canClose = true, onclose }: Props = $props();
let { file, canClose = true, onclose, onedit }: Props = $props();

let showModal = $state(false);

Expand Down Expand Up @@ -61,6 +64,31 @@
mime === "application/vnd.chatui.clipboard" || matchesAllowed(mime, TEXT_MIME_ALLOWLIST);

let isClickable = $derived(isImage(file.mime) || isPlainText(file.mime));

function fromBase64Utf8(b64: string): string {
const binary = atob(b64);
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
return new TextDecoder("utf-8").decode(bytes);
}

async function getText(): Promise<string> {
if (file.type === "hash") {
const res = await fetch(urlNotTrailing + "/output/" + file.value);
return await res.text();
}
return fromBase64Utf8(file.value);
}

async function copyText() {
const text = await getText();
await navigator.clipboard.writeText(text);
}

async function editText() {
const text = await getText();
onedit?.(text);
showModal = false;
}
</script>

{#if showModal && isClickable}
Expand Down Expand Up @@ -95,10 +123,25 @@
</p>
{/if}
<button
class="absolute right-4 top-4 text-xl text-gray-500 hover:text-gray-800 dark:text-gray-400 dark:hover:text-white"
class="text-md absolute right-24 top-5 text-gray-500 hover:text-gray-800 dark:text-gray-400 dark:hover:text-white"
onclick={copyText}
>
<CopyToClipBoardBtn value="" />
</button>

<button
class="text-md absolute right-14 top-5 text-gray-500 hover:text-gray-800 dark:text-gray-400 dark:hover:text-white"
onclick={editText}
title="Edit"
>
<CarbonPen />
</button>
<button
class="absolute right-4 top-4 text-2xl text-gray-500 hover:text-gray-800 dark:text-gray-400 dark:hover:text-white"
onclick={() => (showModal = false)}
title="Close"
>
<CarbonClose class="text-xl" />
<CarbonClose />
</button>
{#if file.type === "hash"}
{#await fetch(urlNotTrailing + "/output/" + file.value).then((res) => res.text())}
Expand All @@ -119,7 +162,7 @@
class:font-sans={file.mime === "text/plain" ||
file.mime === "application/vnd.chatui.clipboard"}
class:font-mono={file.mime !== "text/plain" &&
file.mime !== "application/vnd.chatui.clipboard"}>{atob(file.value)}</pre>
file.mime !== "application/vnd.chatui.clipboard"}>{fromBase64Utf8(file.value)}</pre>
{/if}
</div>
{/if}
Expand Down
Loading