This commit is contained in:
Timothy Jaeryang Baek
2026-06-29 13:30:28 -05:00
parent 22a44e67a8
commit 0443ab3a61
8 changed files with 741 additions and 425 deletions
+101 -114
View File
@@ -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 = [];
+21 -18
View File
@@ -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;
}