Files
open-webui/src/lib/components/chat/Messages/structuredOutput.ts
T
2026-08-14 00:22:17 -06:00

572 lines
15 KiB
TypeScript

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;
status?: string;
};
};
export type OutputDisplayItem =
| {
type: 'message';
id: string;
text: string;
}
| {
type: 'detail_single';
id: string;
token: OutputDetailToken;
}
| {
type: 'detail_group';
id: string;
tokens: OutputDetailToken[];
};
type ResponseStreamEvent = {
type?: string;
item_id?: string;
output_index?: number;
content_index?: number;
summary_index?: number;
item?: OutputItem;
part?: OutputContentPart;
delta?: unknown;
text?: unknown;
arguments?: unknown;
response?: {
output?: OutputItem[];
[key: string]: unknown;
};
[key: string]: unknown;
};
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 {
const summary = Array.isArray(item.summary) && item.summary.length ? item.summary : null;
return getTextFromParts(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 ?? item.id ?? '';
const resultItem = toolOutputByCallId[callId];
const status = String(item.status ?? '');
const isDone = isDoneStatus(item.status) || !!resultItem;
let name = item.name ?? '';
if (name === 'delegate_task') {
try {
const args =
typeof item.arguments === 'string'
? JSON.parse(item.arguments || '{}')
: (item.arguments ?? {});
const task = typeof args.task === 'string' && args.task ? args.task : '?';
const label = args.background ? 'Background sub-agent' : 'Sub-agent';
name = `${label}: "${task.length > 60 ? `${task.slice(0, 60)}...` : task}"`;
} catch {
name = 'Sub-agent';
}
}
return {
summary:
status === 'pending' ? 'Tool Approval Needed' : isDone ? 'Tool Executed' : 'Executing...',
text: getToolResultText(resultItem),
attributes: {
type: 'tool_calls',
id: callId,
name,
done: isDone ? 'true' : 'false',
status,
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 === 'function_call' &&
item.name === 'ask_user' &&
(item.status === 'pending' || item.status === 'in_progress')
) {
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');
}
function appendDelta(current: unknown, delta: unknown): unknown {
if (typeof current === 'string' || typeof delta === 'string') {
return `${current ?? ''}${delta ?? ''}`;
}
if (
current &&
delta &&
typeof current === 'object' &&
typeof delta === 'object' &&
!Array.isArray(current) &&
!Array.isArray(delta)
) {
return { ...(current as Record<string, unknown>), ...(delta as Record<string, unknown>) };
}
return delta ?? current ?? '';
}
function ensureItem(output: OutputItem[], outputIndex: number, fallback?: OutputItem): OutputItem {
while (output.length <= outputIndex) {
output.push(
fallback ?? { type: 'message', status: 'in_progress', role: 'assistant', content: [] }
);
}
output[outputIndex] = { ...output[outputIndex] };
return output[outputIndex];
}
function ensurePart(parts: OutputContentPart[], index: number, fallback?: OutputContentPart) {
while (parts.length <= index) {
parts.push(fallback ?? { type: 'output_text', text: '' });
}
parts[index] = { ...parts[index] };
return parts[index];
}
function findOutputItemIndex(output: OutputItem[], item: OutputItem): number {
return output.findIndex(
(existing) =>
(!!item.id && existing?.id === item.id) ||
(!!item.call_id && existing?.call_id === item.call_id)
);
}
export function applyResponseStreamEvent(
output: OutputItem[] = [],
event: ResponseStreamEvent
): OutputItem[] {
const eventType = event?.type ?? '';
if (!eventType.startsWith('response.')) {
return output;
}
if (eventType === 'response.completed') {
return event.response?.output ? [...event.response.output] : output;
}
const nextOutput = [...output];
const eventItemIndex = event.item_id
? nextOutput.findIndex((item) => item?.id === event.item_id || item?.call_id === event.item_id)
: -1;
const outputIndex =
eventItemIndex >= 0 ? eventItemIndex : (event.output_index ?? Math.max(output.length - 1, 0));
if (eventType === 'response.output_item.added') {
if (!event.item) {
return output;
}
const item = { ...event.item };
const existingIndex = findOutputItemIndex(nextOutput, item);
if (existingIndex >= 0) {
nextOutput[existingIndex] = item;
} else if (outputIndex < nextOutput.length) {
nextOutput.splice(outputIndex, 0, item);
} else {
nextOutput[outputIndex] = item;
}
return nextOutput;
}
if (eventType === 'response.output_item.done') {
if (!event.item) {
return output;
}
const item = { ...event.item };
const existingIndex = findOutputItemIndex(nextOutput, item);
nextOutput[existingIndex >= 0 ? existingIndex : outputIndex] = item;
return nextOutput;
}
const item = ensureItem(nextOutput, outputIndex, {
id: event.item_id,
type: eventType.includes('reasoning')
? 'reasoning'
: eventType.includes('function_call')
? 'function_call'
: 'message',
status: 'in_progress',
role: 'assistant',
content: []
});
if (eventType === 'response.content_part.added') {
if (item.type === 'reasoning' || !event.part) {
return nextOutput;
}
item.content = [...(item.content ?? [])];
item.content[event.content_index ?? item.content.length] = { ...event.part };
return nextOutput;
}
if (eventType === 'response.reasoning_summary_part.added') {
if (!event.part) {
return nextOutput;
}
item.summary = [...(item.summary ?? [])];
item.summary[event.summary_index ?? item.summary.length] = { ...event.part };
return nextOutput;
}
if (eventType.endsWith('.delta')) {
const deltaType = eventType.split('.')[1];
if (deltaType === 'function_call_arguments') {
item.arguments = appendDelta(item.arguments ?? '', event.delta);
return nextOutput;
}
if (deltaType === 'reasoning_summary_text') {
const summaryIndex = event.summary_index ?? 0;
item.summary = [...(item.summary ?? [])];
const part = ensurePart(item.summary, summaryIndex, { type: 'summary_text', text: '' });
part.text = appendDelta(part.text ?? '', event.delta);
return nextOutput;
}
const key = deltaType === 'output_text' || deltaType === 'reasoning_text' ? 'text' : deltaType;
item.content = [...(item.content ?? [])];
const part = ensurePart(item.content, event.content_index ?? 0);
part[key] = appendDelta(part[key], event.delta);
return nextOutput;
}
if (eventType.endsWith('.done')) {
const typeName = eventType.split('.')[1];
if (typeName === 'content_part' && event.part) {
item.content = [...(item.content ?? [])];
item.content[event.content_index ?? Math.max(item.content.length - 1, 0)] = { ...event.part };
} else if (typeName === 'function_call_arguments' && event.arguments !== undefined) {
item.arguments = event.arguments;
} else if (
(typeName === 'output_text' || typeName === 'text' || typeName === 'reasoning_text') &&
event.text !== undefined
) {
item.content = [...(item.content ?? [])];
const part = ensurePart(item.content, event.content_index ?? 0);
part.text = event.text;
}
}
return nextOutput;
}
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;
}