refac
This commit is contained in:
+101
-114
@@ -68,6 +68,7 @@
|
||||
displayFileHandler
|
||||
} from '$lib/utils';
|
||||
import { AudioQueue } from '$lib/utils/audio';
|
||||
import { getOutputText } from './Messages/structuredOutput';
|
||||
|
||||
import {
|
||||
archiveChatById,
|
||||
@@ -1248,13 +1249,50 @@
|
||||
|
||||
$: onHistoryChange(history);
|
||||
|
||||
const dispatchCallOverlayAudio = (message, final = false) => {
|
||||
if (!$showCallOverlay) {
|
||||
return;
|
||||
}
|
||||
|
||||
const messageContentParts = getMessageContentParts(
|
||||
getOutputText(message?.output) || removeAllDetails(message?.content ?? ''),
|
||||
$config?.audio?.tts?.split_on ?? 'punctuation'
|
||||
);
|
||||
if (!final) {
|
||||
messageContentParts.pop();
|
||||
}
|
||||
|
||||
const nextContentPart = messageContentParts.at(-1) ?? '';
|
||||
if (!nextContentPart || (!final && nextContentPart === message.lastSentence)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!final) {
|
||||
message.lastSentence = nextContentPart;
|
||||
}
|
||||
|
||||
eventTarget.dispatchEvent(
|
||||
new CustomEvent('chat', {
|
||||
detail: {
|
||||
id: message.id,
|
||||
content: nextContentPart
|
||||
}
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const getContents = () => {
|
||||
const messages = history ? createMessagesList(history, history.currentId) : [];
|
||||
let contents = [];
|
||||
messages.forEach((message) => {
|
||||
if (message?.role !== 'user' && message?.content) {
|
||||
if (message?.role !== 'user') {
|
||||
const messageContent = getOutputText(message?.output) || removeAllDetails(message?.content ?? '');
|
||||
if (!messageContent.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { codeBlocks: codeBlocks, htmlGroups: htmlGroups } = getCodeBlockContents(
|
||||
message.content
|
||||
messageContent
|
||||
);
|
||||
|
||||
if (htmlGroups && htmlGroups.length > 0) {
|
||||
@@ -1923,6 +1961,7 @@
|
||||
// Store raw OR-aligned output items from backend
|
||||
if (output) {
|
||||
message.output = output;
|
||||
dispatchCallOverlayAudio(message);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
@@ -1933,10 +1972,11 @@
|
||||
message.sources = sources;
|
||||
}
|
||||
|
||||
if (choices) {
|
||||
if (choices && !output) {
|
||||
if (choices[0]?.message?.content) {
|
||||
// Non-stream response
|
||||
message.content += choices[0]?.message?.content;
|
||||
dispatchCallOverlayAudio(message);
|
||||
} else {
|
||||
// Stream response
|
||||
let value = choices[0]?.delta?.content ?? '';
|
||||
@@ -1948,67 +1988,19 @@
|
||||
if (navigator.vibrate && ($settings?.hapticFeedback ?? false)) {
|
||||
navigator.vibrate(5);
|
||||
}
|
||||
|
||||
// Emit chat event for TTS (only when call overlay is active)
|
||||
if ($showCallOverlay) {
|
||||
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]
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
dispatchCallOverlayAudio(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (content) {
|
||||
if (content && !output) {
|
||||
// REALTIME_CHAT_SAVE is disabled
|
||||
message.content = content;
|
||||
|
||||
if (navigator.vibrate && ($settings?.hapticFeedback ?? false)) {
|
||||
navigator.vibrate(5);
|
||||
}
|
||||
|
||||
// Emit chat event for TTS (only when call overlay is active)
|
||||
if ($showCallOverlay) {
|
||||
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]
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
dispatchCallOverlayAudio(message);
|
||||
}
|
||||
|
||||
if (selected_model_id) {
|
||||
@@ -2024,9 +2016,10 @@
|
||||
|
||||
if (done) {
|
||||
message.done = true;
|
||||
const visibleContent = getOutputText(message?.output) || removeAllDetails(message?.content ?? '');
|
||||
|
||||
if ($settings.responseAutoCopy) {
|
||||
copyToClipboard(message.content);
|
||||
copyToClipboard(visibleContent);
|
||||
}
|
||||
|
||||
if ($settings.responseAutoPlayback && !$showCallOverlay) {
|
||||
@@ -2035,25 +2028,12 @@
|
||||
}
|
||||
|
||||
// Emit chat event for TTS (only when call overlay is active)
|
||||
if ($showCallOverlay) {
|
||||
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 }
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
dispatchCallOverlayAudio(message, true);
|
||||
eventTarget.dispatchEvent(
|
||||
new CustomEvent('chat:finish', {
|
||||
detail: {
|
||||
id: message.id,
|
||||
content: message.content
|
||||
content: visibleContent
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -2480,53 +2460,60 @@
|
||||
true;
|
||||
// Always include system prompt — backend extracts it and prepends to DB messages.
|
||||
// Only temp chats need conversation messages (persisted chats load from DB).
|
||||
let messages = [
|
||||
params?.system || $settings.system
|
||||
? { role: 'system', content: `${params?.system ?? $settings?.system ?? ''}` }
|
||||
: undefined
|
||||
].filter(Boolean);
|
||||
let messages: any[] = [
|
||||
params?.system || $settings.system
|
||||
? { role: 'system', content: `${params?.system ?? $settings?.system ?? ''}` }
|
||||
: undefined
|
||||
].filter(Boolean);
|
||||
|
||||
if ($temporaryChatEnabled) {
|
||||
messages = [
|
||||
...messages,
|
||||
..._messages.map((message) => ({
|
||||
...message,
|
||||
content: processDetails(message.content),
|
||||
...(message.output ? { output: message.output } : {})
|
||||
}))
|
||||
].filter((message) => message);
|
||||
if ($temporaryChatEnabled) {
|
||||
messages = [
|
||||
...messages,
|
||||
..._messages.map((message) => ({
|
||||
...message,
|
||||
...(message.output && message.role === 'assistant'
|
||||
? { output: message.output }
|
||||
: { content: processDetails(message.content) })
|
||||
}))
|
||||
].filter((message) => message);
|
||||
|
||||
messages = messages
|
||||
.map((message, idx, arr) => {
|
||||
const imageFiles = (message?.files ?? []).filter(
|
||||
(file) => file.type === 'image' || (file?.content_type ?? '').startsWith('image/')
|
||||
messages = messages
|
||||
.map((message) => {
|
||||
const imageFiles = (message?.files ?? []).filter(
|
||||
(file) => file.type === 'image' || (file?.content_type ?? '').startsWith('image/')
|
||||
);
|
||||
|
||||
if (message.output && message.role === 'assistant') {
|
||||
return { role: message.role, output: message.output };
|
||||
}
|
||||
|
||||
if (message.role === 'user' && imageFiles.length > 0) {
|
||||
return {
|
||||
role: message.role,
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: message?.merged?.content ?? message.content
|
||||
},
|
||||
...imageFiles.map((file) => ({
|
||||
type: 'image_url',
|
||||
image_url: {
|
||||
url: file.url
|
||||
}
|
||||
}))
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
role: message.role,
|
||||
content: message?.merged?.content ?? message.content
|
||||
};
|
||||
})
|
||||
.filter(
|
||||
(message) => message?.role === 'user' || message?.content?.trim() || message?.output?.length
|
||||
);
|
||||
|
||||
return {
|
||||
role: message.role,
|
||||
...(message.output ? { output: message.output } : {}),
|
||||
...(message.role === 'user' && imageFiles.length > 0
|
||||
? {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: message?.merged?.content ?? message.content
|
||||
},
|
||||
...imageFiles.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 = [];
|
||||
|
||||
@@ -173,7 +173,7 @@
|
||||
messages: messages
|
||||
});
|
||||
|
||||
// Refresh local message content from backend (e.g. re-derived via serialize_output)
|
||||
// Keep local plain-content edits aligned with the saved chat response.
|
||||
if (res?.chat?.history?.messages) {
|
||||
for (const [id, msg] of Object.entries(res.chat.history.messages)) {
|
||||
if (history.messages[id] && (msg as any).content) {
|
||||
@@ -385,16 +385,16 @@
|
||||
const message = history.messages[messageId];
|
||||
const parentId = message.parentId;
|
||||
|
||||
const responseMessage = {
|
||||
...message,
|
||||
id: responseMessageId,
|
||||
parentId: parentId,
|
||||
childrenIds: [],
|
||||
files: undefined,
|
||||
content: content,
|
||||
output: output ?? undefined,
|
||||
timestamp: Math.floor(Date.now() / 1000) // Unix epoch
|
||||
};
|
||||
const responseMessage = {
|
||||
...message,
|
||||
id: responseMessageId,
|
||||
parentId: parentId,
|
||||
childrenIds: [],
|
||||
files: undefined,
|
||||
content: output !== undefined ? '' : content,
|
||||
...(output !== undefined ? { output } : {}),
|
||||
timestamp: Math.floor(Date.now() / 1000) // Unix epoch
|
||||
};
|
||||
|
||||
history.messages[responseMessageId] = responseMessage;
|
||||
history.currentId = responseMessageId;
|
||||
@@ -408,13 +408,16 @@
|
||||
}
|
||||
|
||||
await updateChat();
|
||||
} else {
|
||||
// Edit response message
|
||||
history.messages[messageId].originalContent = history.messages[messageId].content;
|
||||
history.messages[messageId].content = content;
|
||||
if (output !== undefined) {
|
||||
history.messages[messageId].output = output;
|
||||
}
|
||||
} else {
|
||||
// Edit response message
|
||||
if (content !== undefined) {
|
||||
history.messages[messageId].originalContent = history.messages[messageId].content;
|
||||
history.messages[messageId].content = content;
|
||||
}
|
||||
if (output !== undefined) {
|
||||
history.messages[messageId].output = output;
|
||||
history.messages[messageId].content = '';
|
||||
}
|
||||
await updateChat();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
import Markdown from './Markdown.svelte';
|
||||
import StructuredOutputRenderer from './StructuredOutputRenderer.svelte';
|
||||
import {
|
||||
artifactCode,
|
||||
chatId,
|
||||
@@ -68,6 +69,8 @@
|
||||
|
||||
export let id;
|
||||
export let content;
|
||||
/** @type {import('./structuredOutput').OutputItem[]} */
|
||||
export let output = [];
|
||||
|
||||
export let history;
|
||||
export let messageId;
|
||||
@@ -118,6 +121,39 @@
|
||||
sourceIds = [...new Set(result)];
|
||||
};
|
||||
|
||||
/** @param {string} messageContent */
|
||||
const formatMessageContent = (messageContent) =>
|
||||
model?.info?.meta?.capabilities?.citations == false
|
||||
? replaceOutsideCode(messageContent, (segment) =>
|
||||
segment.replace(/\s*(\[(?:\d+(?:#[^,\]\s]+)?(?:,\s*\d+(?:#[^,\]\s]+)?)*)\])+/g, '')
|
||||
)
|
||||
: messageContent;
|
||||
|
||||
const markdownUpdateHandler = /** @type {any} */ (async (
|
||||
/** @type {{ lang?: string; text?: string }} */ token
|
||||
) => {
|
||||
const { lang = '', text: code = '' } = token;
|
||||
|
||||
if (
|
||||
($settings?.detectArtifacts ?? true) &&
|
||||
(['html', 'svg'].includes(lang) || (lang === 'xml' && code.includes('svg'))) &&
|
||||
!$mobile &&
|
||||
$chatId
|
||||
) {
|
||||
await tick();
|
||||
showArtifacts.set(true);
|
||||
showControls.set(true);
|
||||
}
|
||||
});
|
||||
|
||||
const previewHandler = /** @type {any} */ (async (/** @type {string} */ value) => {
|
||||
console.log('Preview', value);
|
||||
await artifactCode.set(/** @type {any} */ (value));
|
||||
await showControls.set(true);
|
||||
await showArtifacts.set(true);
|
||||
await showEmbeds.set(false);
|
||||
});
|
||||
|
||||
const updateButtonPosition = (event) => {
|
||||
const buttonsContainerElement = document.getElementById(`floating-buttons-${id}`);
|
||||
if (
|
||||
@@ -225,14 +261,28 @@
|
||||
</script>
|
||||
|
||||
<div bind:this={contentContainerElement}>
|
||||
{#if $settings?.renderMarkdownInAssistantMessages ?? true}
|
||||
{#if output?.length}
|
||||
<StructuredOutputRenderer
|
||||
{id}
|
||||
{output}
|
||||
{model}
|
||||
{preview}
|
||||
{done}
|
||||
{editCodeBlock}
|
||||
{topPadding}
|
||||
{sourceIds}
|
||||
renderMarkdown={$settings?.renderMarkdownInAssistantMessages ?? true}
|
||||
formatMessageContent={formatMessageContent}
|
||||
{onSourceClick}
|
||||
{onTaskClick}
|
||||
{onSave}
|
||||
onUpdate={markdownUpdateHandler}
|
||||
onPreview={previewHandler}
|
||||
/>
|
||||
{:else if $settings?.renderMarkdownInAssistantMessages ?? true}
|
||||
<Markdown
|
||||
{id}
|
||||
content={model?.info?.meta?.capabilities?.citations == false
|
||||
? replaceOutsideCode(content, (segment) =>
|
||||
segment.replace(/\s*(\[(?:\d+(?:#[^,\]\s]+)?(?:,\s*\d+(?:#[^,\]\s]+)?)*)\])+/g, '')
|
||||
)
|
||||
: content}
|
||||
content={formatMessageContent(content)}
|
||||
{model}
|
||||
{save}
|
||||
{preview}
|
||||
@@ -243,27 +293,8 @@
|
||||
{onSourceClick}
|
||||
{onTaskClick}
|
||||
{onSave}
|
||||
onUpdate={async (token) => {
|
||||
const { lang, text: code } = token;
|
||||
|
||||
if (
|
||||
($settings?.detectArtifacts ?? true) &&
|
||||
(['html', 'svg'].includes(lang) || (lang === 'xml' && code.includes('svg'))) &&
|
||||
!$mobile &&
|
||||
$chatId
|
||||
) {
|
||||
await tick();
|
||||
showArtifacts.set(true);
|
||||
showControls.set(true);
|
||||
}
|
||||
}}
|
||||
onPreview={async (value) => {
|
||||
console.log('Preview', value);
|
||||
await artifactCode.set(value);
|
||||
await showControls.set(true);
|
||||
await showArtifacts.set(true);
|
||||
await showEmbeds.set(false);
|
||||
}}
|
||||
onUpdate={markdownUpdateHandler}
|
||||
onPreview={previewHandler}
|
||||
/>
|
||||
{:else}
|
||||
{@const extracted = extractDetailsBlocks(content)}
|
||||
|
||||
@@ -64,11 +64,17 @@
|
||||
import StatusHistory from './ResponseMessage/StatusHistory.svelte';
|
||||
import FullHeightIframe from '$lib/components/common/FullHeightIframe.svelte';
|
||||
import OutputEditView from './OutputEditView.svelte';
|
||||
import {
|
||||
getOutputText,
|
||||
replaceOutputMessageText,
|
||||
type OutputItem
|
||||
} from './structuredOutput';
|
||||
|
||||
interface MessageType {
|
||||
id: string;
|
||||
model: string;
|
||||
content: string;
|
||||
output?: OutputItem[];
|
||||
files?: { type: string; url: string }[];
|
||||
timestamp: number;
|
||||
role: string;
|
||||
@@ -127,7 +133,11 @@
|
||||
if (source) {
|
||||
// Fast path: O(1) check on the fields that change most often (content during streaming, done at end)
|
||||
// Avoids 2x O(n) JSON.stringify calls that are always true during streaming anyway
|
||||
if (message.content !== source.content || message.done !== source.done) {
|
||||
if (
|
||||
message.content !== source.content ||
|
||||
message.done !== source.done ||
|
||||
message.output?.length !== source.output?.length
|
||||
) {
|
||||
message = structuredClone(source);
|
||||
} else if (!equal(message, source)) {
|
||||
// Slow path: full comparison for infrequent changes (sources, annotations, status, etc.)
|
||||
@@ -175,6 +185,8 @@
|
||||
(model?.info?.meta?.capabilities?.status_updates ?? true) &&
|
||||
statusEntries.length > 0 &&
|
||||
!(statusEntries.at(-1)?.hidden ?? false);
|
||||
$: visibleResponseContent = getOutputText(message.output) || removeAllDetails(message.content ?? '');
|
||||
$: hasResponseContent = Boolean((message.content ?? '').trim() || message.output?.length);
|
||||
|
||||
let edit = false;
|
||||
let editedContent = '';
|
||||
@@ -226,7 +238,8 @@
|
||||
: $config?.audio?.tts?.voice);
|
||||
|
||||
const speak = async () => {
|
||||
if (!(message?.content ?? '').trim().length) {
|
||||
const content = visibleResponseContent;
|
||||
if (!content.trim().length) {
|
||||
toast.info($i18n.t('No content to speak'));
|
||||
return;
|
||||
}
|
||||
@@ -236,7 +249,6 @@
|
||||
const { signal } = speakAbort;
|
||||
|
||||
speaking = true;
|
||||
const content = removeAllDetails(message.content);
|
||||
|
||||
if ($config.audio.tts.engine === '') {
|
||||
let voices = [];
|
||||
@@ -370,17 +382,6 @@
|
||||
return restoredContent;
|
||||
}
|
||||
|
||||
/** Extract plain text from output items for immediate display after edit.
|
||||
* NOT a serialize_output port — just grabs text parts. Backend re-serializes
|
||||
* the full rich content (with <details> blocks) on save. */
|
||||
function extractTextFromOutput(output: any[]): string {
|
||||
return output
|
||||
.filter((item) => item.type === 'message')
|
||||
.flatMap((item) => (item.content ?? []).map((p: any) => p.text ?? ''))
|
||||
.join('\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
const editMessageHandler = async () => {
|
||||
edit = true;
|
||||
|
||||
@@ -407,9 +408,7 @@
|
||||
|
||||
const editMessageConfirmHandler = async () => {
|
||||
if (editedOutput) {
|
||||
// Structured edit: keep original rich content for immediate display;
|
||||
// backend will re-derive content from output on save.
|
||||
editMessage(message.id, { content: message.content, output: editedOutput }, false);
|
||||
editMessage(message.id, { output: editedOutput }, false);
|
||||
} else {
|
||||
// Legacy text edit
|
||||
const messageContent = postprocessAfterEditing(editedContent ?? '');
|
||||
@@ -425,7 +424,7 @@
|
||||
|
||||
const saveAsCopyHandler = async () => {
|
||||
if (editedOutput) {
|
||||
editMessage(message.id, { content: message.content, output: editedOutput });
|
||||
editMessage(message.id, { output: editedOutput });
|
||||
} else {
|
||||
const messageContent = postprocessAfterEditing(editedContent ?? '');
|
||||
editMessage(message.id, { content: messageContent });
|
||||
@@ -826,14 +825,15 @@
|
||||
class="w-full flex flex-col relative {edit ? 'hidden' : ''}"
|
||||
id="response-content-container"
|
||||
>
|
||||
{#if message.content === '' && !message.done && !message.error && !hasVisibleStatus}
|
||||
{#if !hasResponseContent && !message.done && !message.error && !hasVisibleStatus}
|
||||
<Skeleton />
|
||||
{:else if message.content && message.error !== true}
|
||||
{:else if hasResponseContent && message.error !== true}
|
||||
<!-- always show message contents even if there's an error -->
|
||||
<!-- unless message.error === true which is legacy error handling, where the error message is stored in message.content -->
|
||||
<ContentRenderer
|
||||
id={`${chatId}-${message.id}`}
|
||||
content={message.content}
|
||||
output={message.output}
|
||||
sources={message.sources}
|
||||
floatingButtons={message?.done &&
|
||||
!readOnly &&
|
||||
@@ -860,9 +860,27 @@
|
||||
setInputText(text);
|
||||
}}
|
||||
onSave={({ raw, oldContent, newContent }) => {
|
||||
history.messages[message.id].content = history.messages[
|
||||
message.id
|
||||
].content.replace(raw, raw.replace(oldContent, newContent));
|
||||
const sourceMessage = history.messages[message.id];
|
||||
if (sourceMessage.output?.length) {
|
||||
const updatedOutput = replaceOutputMessageText(
|
||||
sourceMessage.output,
|
||||
oldContent,
|
||||
newContent
|
||||
);
|
||||
if (updatedOutput !== sourceMessage.output) {
|
||||
sourceMessage.output = updatedOutput;
|
||||
} else {
|
||||
sourceMessage.content = sourceMessage.content.replace(
|
||||
raw,
|
||||
raw.replace(oldContent, newContent)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
sourceMessage.content = sourceMessage.content.replace(
|
||||
raw,
|
||||
raw.replace(oldContent, newContent)
|
||||
);
|
||||
}
|
||||
|
||||
updateChat();
|
||||
}}
|
||||
@@ -1033,7 +1051,7 @@
|
||||
? 'visible'
|
||||
: 'invisible group-hover:visible'} p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-white hover:text-black transition copy-response-button"
|
||||
on:click={() => {
|
||||
copyToClipboard(message.content);
|
||||
copyToClipboard(visibleResponseContent);
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
<script lang="ts">
|
||||
import Collapsible from '$lib/components/common/Collapsible.svelte';
|
||||
import ToolCallDisplay from '$lib/components/common/ToolCallDisplay.svelte';
|
||||
import { settings } from '$lib/stores';
|
||||
|
||||
import Markdown from './Markdown.svelte';
|
||||
import ConsecutiveDetailsGroup from './Markdown/ConsecutiveDetailsGroup.svelte';
|
||||
import {
|
||||
buildOutputDisplayItems,
|
||||
type OutputDetailToken,
|
||||
type OutputDisplayItem,
|
||||
type OutputItem
|
||||
} from './structuredOutput';
|
||||
|
||||
export let id = '';
|
||||
export let output: OutputItem[] = [];
|
||||
export let done = true;
|
||||
export let model = null;
|
||||
export let save = false;
|
||||
export let preview = false;
|
||||
export let renderMarkdown = true;
|
||||
export let editCodeBlock = true;
|
||||
export let topPadding = false;
|
||||
export let sourceIds: string[] = [];
|
||||
export let formatMessageContent: (content: string) => string = (content) => content;
|
||||
export let onSave: any = () => {};
|
||||
export let onSourceClick: any = () => {};
|
||||
export let onTaskClick: any = () => {};
|
||||
export let onUpdate: any = () => {};
|
||||
export let onPreview: any = () => {};
|
||||
|
||||
const getDetailTitle = (detailToken: OutputDetailToken): any => detailToken.summary;
|
||||
const getDetailAttributes = (detailToken: OutputDetailToken): any => detailToken.attributes;
|
||||
|
||||
$: displayItems = buildOutputDisplayItems(output) as OutputDisplayItem[];
|
||||
</script>
|
||||
|
||||
{#each displayItems as displayItem (displayItem.id)}
|
||||
{#if displayItem.type === 'message'}
|
||||
{#if renderMarkdown}
|
||||
<Markdown
|
||||
id={`${id}-${displayItem.id}`}
|
||||
content={formatMessageContent(displayItem.text)}
|
||||
{model}
|
||||
{save}
|
||||
{preview}
|
||||
{done}
|
||||
{editCodeBlock}
|
||||
{topPadding}
|
||||
{sourceIds}
|
||||
{onSourceClick}
|
||||
{onTaskClick}
|
||||
{onSave}
|
||||
{onUpdate}
|
||||
{onPreview}
|
||||
/>
|
||||
{:else}
|
||||
<div class="whitespace-pre-wrap">{displayItem.text}</div>
|
||||
{/if}
|
||||
{:else if displayItem.type === 'detail_group'}
|
||||
<ConsecutiveDetailsGroup
|
||||
id={`${id}-${displayItem.id}`}
|
||||
tokens={displayItem.tokens}
|
||||
messageDone={done}
|
||||
>
|
||||
<div slot="content" class="space-y-1">
|
||||
{#each displayItem.tokens as detailToken, detailIndex}
|
||||
{#if detailToken.attributes?.type === 'tool_calls'}
|
||||
<ToolCallDisplay
|
||||
id={`${id}-${displayItem.id}-${detailIndex}-tool-call`}
|
||||
attributes={detailToken.attributes}
|
||||
resultContent={detailToken.text}
|
||||
grouped={true}
|
||||
open={$settings?.expandDetails ?? false}
|
||||
className="w-full space-y-1"
|
||||
/>
|
||||
{:else if detailToken.text?.length > 0}
|
||||
<Collapsible
|
||||
title={getDetailTitle(detailToken)}
|
||||
open={$settings?.expandDetails ?? false}
|
||||
attributes={getDetailAttributes(detailToken)}
|
||||
messageDone={done}
|
||||
className="w-full space-y-1"
|
||||
>
|
||||
<div class="mb-1.5" slot="content">
|
||||
<Markdown
|
||||
id={`${id}-${displayItem.id}-${detailIndex}-detail`}
|
||||
content={detailToken.text}
|
||||
{done}
|
||||
{editCodeBlock}
|
||||
/>
|
||||
</div>
|
||||
</Collapsible>
|
||||
{:else}
|
||||
<Collapsible
|
||||
title={getDetailTitle(detailToken)}
|
||||
open={false}
|
||||
disabled={true}
|
||||
attributes={getDetailAttributes(detailToken)}
|
||||
messageDone={done}
|
||||
className="w-full space-y-1"
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</ConsecutiveDetailsGroup>
|
||||
{:else}
|
||||
{@const detailToken = displayItem.token}
|
||||
{#if detailToken.attributes?.type === 'tool_calls'}
|
||||
<ToolCallDisplay
|
||||
id={`${id}-${displayItem.id}-tool-call`}
|
||||
attributes={detailToken.attributes}
|
||||
resultContent={detailToken.text}
|
||||
open={$settings?.expandDetails ?? false}
|
||||
className="w-full space-y-1"
|
||||
/>
|
||||
{:else if detailToken.text?.length > 0}
|
||||
<Collapsible
|
||||
title={getDetailTitle(detailToken)}
|
||||
open={$settings?.expandDetails ?? false}
|
||||
attributes={getDetailAttributes(detailToken)}
|
||||
messageDone={done}
|
||||
className="w-full space-y-1"
|
||||
>
|
||||
<div class="mb-1.5" slot="content">
|
||||
<Markdown
|
||||
id={`${id}-${displayItem.id}-detail`}
|
||||
content={detailToken.text}
|
||||
{done}
|
||||
{editCodeBlock}
|
||||
/>
|
||||
</div>
|
||||
</Collapsible>
|
||||
{:else}
|
||||
<Collapsible
|
||||
title={getDetailTitle(detailToken)}
|
||||
open={false}
|
||||
disabled={true}
|
||||
attributes={getDetailAttributes(detailToken)}
|
||||
messageDone={done}
|
||||
className="w-full space-y-1"
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
{/each}
|
||||
@@ -0,0 +1,365 @@
|
||||
export type OutputContentPart = {
|
||||
type?: string;
|
||||
text?: unknown;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type OutputItem = {
|
||||
type?: string;
|
||||
id?: string;
|
||||
call_id?: string;
|
||||
name?: string;
|
||||
status?: string;
|
||||
arguments?: unknown;
|
||||
content?: OutputContentPart[];
|
||||
summary?: OutputContentPart[];
|
||||
output?: OutputContentPart[];
|
||||
files?: unknown;
|
||||
embeds?: unknown;
|
||||
code?: string;
|
||||
lang?: string;
|
||||
duration?: number | string | null;
|
||||
action?: Record<string, unknown>;
|
||||
actions?: Array<Record<string, unknown>>;
|
||||
queries?: unknown[];
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type OutputDetailToken = {
|
||||
summary: string;
|
||||
text: string;
|
||||
attributes: {
|
||||
type: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
done?: string;
|
||||
duration?: string;
|
||||
arguments?: string;
|
||||
files?: string;
|
||||
embeds?: string;
|
||||
output?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type OutputDisplayItem =
|
||||
| {
|
||||
type: 'message';
|
||||
id: string;
|
||||
text: string;
|
||||
}
|
||||
| {
|
||||
type: 'detail_single';
|
||||
id: string;
|
||||
token: OutputDetailToken;
|
||||
}
|
||||
| {
|
||||
type: 'detail_group';
|
||||
id: string;
|
||||
tokens: OutputDetailToken[];
|
||||
};
|
||||
|
||||
const GROUPABLE_OUTPUT_TYPES = new Set([
|
||||
'reasoning',
|
||||
'function_call',
|
||||
'open_webui:code_interpreter',
|
||||
'web_search_call',
|
||||
'file_search_call',
|
||||
'computer_call'
|
||||
]);
|
||||
|
||||
const OPENAI_TOOL_NAMES: Record<string, string> = {
|
||||
web_search_call: 'Web Search',
|
||||
file_search_call: 'File Search',
|
||||
computer_call: 'Computer Use'
|
||||
};
|
||||
|
||||
function getTextFromParts(parts: OutputContentPart[] = []): string {
|
||||
return parts
|
||||
.map((part) => {
|
||||
if (part?.text === undefined || part?.text === null) {
|
||||
return '';
|
||||
}
|
||||
return typeof part.text === 'string' ? part.text : String(part.text);
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
function stringifyAttribute(value: unknown): string {
|
||||
if (value === undefined || value === null) {
|
||||
return '';
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function isDoneStatus(status?: string): boolean {
|
||||
return status === 'completed' || status === 'failed' || status === 'incomplete';
|
||||
}
|
||||
|
||||
function getMessageText(item: OutputItem): string {
|
||||
return getTextFromParts(item.content ?? []);
|
||||
}
|
||||
|
||||
function getReasoningText(item: OutputItem): string {
|
||||
return getTextFromParts((item.summary ?? item.content) ?? []);
|
||||
}
|
||||
|
||||
function getToolResultText(item?: OutputItem): string {
|
||||
return (item?.output ?? [])
|
||||
.filter((part) => part?.type !== 'input_image')
|
||||
.map((part) => {
|
||||
if (part?.text === undefined || part?.text === null) {
|
||||
return '';
|
||||
}
|
||||
return typeof part.text === 'string' ? part.text : String(part.text);
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
function buildToolCallToken(item: OutputItem, toolOutputByCallId: Record<string, OutputItem>) {
|
||||
const callId = item.call_id ?? '';
|
||||
const resultItem = toolOutputByCallId[callId];
|
||||
const isDone = isDoneStatus(item.status) || !!resultItem;
|
||||
|
||||
return {
|
||||
summary: isDone ? 'Tool Executed' : 'Executing...',
|
||||
text: getToolResultText(resultItem),
|
||||
attributes: {
|
||||
type: 'tool_calls',
|
||||
id: callId,
|
||||
name: item.name ?? '',
|
||||
done: isDone ? 'true' : 'false',
|
||||
arguments: stringifyAttribute(item.arguments ?? ''),
|
||||
files: stringifyAttribute(resultItem?.files),
|
||||
embeds: stringifyAttribute(resultItem?.embeds)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function buildReasoningToken(item: OutputItem, isLastItem: boolean) {
|
||||
const duration = item.duration ?? '';
|
||||
const isDone = isDoneStatus(item.status) || item.duration !== undefined || !isLastItem;
|
||||
const text = getReasoningText(item)
|
||||
.split('\n')
|
||||
.map((line) => (line.startsWith('>') ? line : `> ${line}`))
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
summary: isDone ? `Thought for ${duration || 0} seconds` : 'Thinking...',
|
||||
text,
|
||||
attributes: {
|
||||
type: 'reasoning',
|
||||
done: isDone ? 'true' : 'false',
|
||||
duration: String(duration)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function buildCodeInterpreterToken(item: OutputItem, isLastItem: boolean) {
|
||||
const duration = item.duration ?? '';
|
||||
const isDone = isDoneStatus(item.status) || item.duration !== undefined || !isLastItem;
|
||||
const code = item.code ?? '';
|
||||
const lang = item.lang ?? 'python';
|
||||
|
||||
return {
|
||||
summary: isDone ? 'Analyzed' : 'Analyzing...',
|
||||
text: code ? `\`\`\`${lang}\n${code}\n\`\`\`` : '',
|
||||
attributes: {
|
||||
type: 'code_interpreter',
|
||||
done: isDone ? 'true' : 'false',
|
||||
duration: String(duration),
|
||||
output: stringifyAttribute(item.output)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function getOpenAIToolSummary(item: OutputItem): string {
|
||||
if (item.type === 'web_search_call') {
|
||||
const action = item.action ?? {};
|
||||
const actionType = action.type;
|
||||
if (actionType === 'search') {
|
||||
const queries = Array.isArray(action.queries) ? action.queries : [];
|
||||
const query = typeof action.query === 'string' ? action.query : '';
|
||||
return queries.length ? `Search: ${queries.join(', ')}` : query ? `Search: ${query}` : '';
|
||||
}
|
||||
if (actionType === 'open_page' && typeof action.url === 'string') {
|
||||
return `Open page: ${action.url}`;
|
||||
}
|
||||
if (actionType === 'find_in_page' && typeof action.pattern === 'string') {
|
||||
return `Find in page: ${action.pattern}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (item.type === 'file_search_call') {
|
||||
const queries = item.queries ?? [];
|
||||
return queries.length ? `Queries: ${queries.join(', ')}` : '';
|
||||
}
|
||||
|
||||
if (item.type === 'computer_call') {
|
||||
if (item.action?.type) {
|
||||
return `Action: ${item.action.type}`;
|
||||
}
|
||||
if (Array.isArray(item.actions) && item.actions.length) {
|
||||
return `Actions: ${item.actions.map((action) => action.type ?? '?').join(', ')}`;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function buildOpenAIToolToken(item: OutputItem, isLastItem: boolean) {
|
||||
const isDone = isDoneStatus(item.status) || !isLastItem;
|
||||
return {
|
||||
summary: isDone ? 'Tool Executed' : 'Executing...',
|
||||
text: getOpenAIToolSummary(item),
|
||||
attributes: {
|
||||
type: 'tool_calls',
|
||||
id: item.id ?? '',
|
||||
name: OPENAI_TOOL_NAMES[item.type ?? ''] ?? item.type ?? '',
|
||||
done: isDone ? 'true' : 'false',
|
||||
arguments: ''
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function buildDetailToken(
|
||||
item: OutputItem,
|
||||
isLastItem: boolean,
|
||||
toolOutputByCallId: Record<string, OutputItem>
|
||||
): OutputDetailToken | null {
|
||||
if (item.type === 'function_call') {
|
||||
return buildToolCallToken(item, toolOutputByCallId);
|
||||
}
|
||||
if (item.type === 'reasoning') {
|
||||
return buildReasoningToken(item, isLastItem);
|
||||
}
|
||||
if (item.type === 'open_webui:code_interpreter') {
|
||||
return buildCodeInterpreterToken(item, isLastItem);
|
||||
}
|
||||
if (item.type && OPENAI_TOOL_NAMES[item.type]) {
|
||||
return buildOpenAIToolToken(item, isLastItem);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildOutputDisplayItems(output: OutputItem[] = []): OutputDisplayItem[] {
|
||||
const displayItems: OutputDisplayItem[] = [];
|
||||
const currentDetailTokens: OutputDetailToken[] = [];
|
||||
const toolOutputByCallId: Record<string, OutputItem> = {};
|
||||
|
||||
for (const item of output) {
|
||||
if (item?.type === 'function_call_output' && item.call_id) {
|
||||
toolOutputByCallId[item.call_id] = item;
|
||||
}
|
||||
}
|
||||
|
||||
const flushDetails = () => {
|
||||
if (currentDetailTokens.length > 1) {
|
||||
displayItems.push({
|
||||
type: 'detail_group',
|
||||
id: `detail-group-${displayItems.length}`,
|
||||
tokens: [...currentDetailTokens]
|
||||
});
|
||||
} else if (currentDetailTokens.length === 1) {
|
||||
displayItems.push({
|
||||
type: 'detail_single',
|
||||
id: `detail-${displayItems.length}`,
|
||||
token: currentDetailTokens[0]
|
||||
});
|
||||
}
|
||||
currentDetailTokens.length = 0;
|
||||
};
|
||||
|
||||
output.forEach((item, index) => {
|
||||
if (item?.type === 'function_call_output') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (item?.type && GROUPABLE_OUTPUT_TYPES.has(item.type)) {
|
||||
const token = buildDetailToken(item, index === output.length - 1, toolOutputByCallId);
|
||||
if (token) {
|
||||
currentDetailTokens.push(token);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (item?.type === 'message') {
|
||||
const text = getMessageText(item);
|
||||
if (text.trim()) {
|
||||
flushDetails();
|
||||
displayItems.push({
|
||||
type: 'message',
|
||||
id: item.id ?? `message-${index}`,
|
||||
text
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const fallbackText = getMessageText(item);
|
||||
if (fallbackText.trim()) {
|
||||
flushDetails();
|
||||
displayItems.push({
|
||||
type: 'message',
|
||||
id: item.id ?? `output-${index}`,
|
||||
text: fallbackText
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
flushDetails();
|
||||
return displayItems;
|
||||
}
|
||||
|
||||
export function getOutputText(output?: OutputItem[] | null): string {
|
||||
return (output ?? [])
|
||||
.filter((item) => item?.type === 'message')
|
||||
.map(getMessageText)
|
||||
.filter((text) => text.trim())
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
export function replaceOutputMessageText(
|
||||
output: OutputItem[] = [],
|
||||
oldContent: string,
|
||||
newContent: string
|
||||
): OutputItem[] {
|
||||
if (!oldContent) {
|
||||
return output;
|
||||
}
|
||||
|
||||
let replaced = false;
|
||||
const nextOutput = output.map((item) => {
|
||||
if (replaced || item?.type !== 'message' || !Array.isArray(item.content)) {
|
||||
return item;
|
||||
}
|
||||
|
||||
const partIndex = item.content.findIndex(
|
||||
(part) => typeof part.text === 'string' && part.text.includes(oldContent)
|
||||
);
|
||||
if (partIndex === -1) {
|
||||
return item;
|
||||
}
|
||||
|
||||
replaced = true;
|
||||
const nextContent = [...item.content];
|
||||
const part = nextContent[partIndex];
|
||||
nextContent[partIndex] = {
|
||||
...part,
|
||||
text: (part.text as string).replace(oldContent, newContent)
|
||||
};
|
||||
|
||||
return {
|
||||
...item,
|
||||
content: nextContent
|
||||
};
|
||||
});
|
||||
|
||||
return replaced ? nextOutput : output;
|
||||
}
|
||||
Reference in New Issue
Block a user