Skip to content

Commit 3765a50

Browse files
Merge remote-tracking branch 'upstream/dev' into dev
2 parents e8a632c + 0dc74a8 commit 3765a50

4 files changed

Lines changed: 239 additions & 3 deletions

File tree

backend/open_webui/utils/middleware.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@
9292
prepend_to_first_user_message_content,
9393
convert_logit_bias_input_to_json,
9494
get_content_from_message,
95+
convert_output_to_messages,
9596
)
9697
from open_webui.utils.tools import (
9798
get_tools,
@@ -1399,6 +1400,30 @@ async def convert_url_images_to_base64(form_data):
13991400
return form_data
14001401

14011402

1403+
def process_messages_with_output(messages: list[dict]) -> list[dict]:
1404+
"""
1405+
Process messages with OR-aligned output items for LLM consumption.
1406+
1407+
For assistant messages with 'output' field, produces properly formatted
1408+
OpenAI-style messages (tool_calls + tool results). Strips 'output' before LLM.
1409+
"""
1410+
processed = []
1411+
1412+
for message in messages:
1413+
if message.get("role") == "assistant" and message.get("output"):
1414+
# Use output items for clean OpenAI-format messages
1415+
output_messages = convert_output_to_messages(message["output"])
1416+
if output_messages:
1417+
processed.extend(output_messages)
1418+
continue
1419+
1420+
# Strip 'output' field before adding (LLM shouldn't see it)
1421+
clean_message = {k: v for k, v in message.items() if k != "output"}
1422+
processed.append(clean_message)
1423+
1424+
return processed
1425+
1426+
14021427
async def process_chat_payload(request, form_data, user, metadata, model):
14031428
# Pipeline Inlet -> Filter Inlet -> Chat Memory -> Chat Web Search -> Chat Image Generation
14041429
# -> Chat Code Interpreter (Form Data Update) -> (Default) Chat Tools Function Calling
@@ -1407,6 +1432,9 @@ async def process_chat_payload(request, form_data, user, metadata, model):
14071432
form_data = apply_params_to_form_data(form_data, model)
14081433
log.debug(f"form_data: {form_data}")
14091434

1435+
# Process messages with OR-aligned output items for clean LLM messages
1436+
form_data["messages"] = process_messages_with_output(form_data.get("messages", []))
1437+
14101438
system_message = get_system_message(form_data.get("messages", []))
14111439
if system_message: # Chat Controls/User Settings
14121440
try:
@@ -2517,6 +2545,87 @@ def convert_content_blocks_to_messages(content_blocks, raw=False):
25172545

25182546
return messages
25192547

2548+
def convert_content_blocks_to_output(content_blocks):
2549+
"""
2550+
Convert content_blocks to Open Responses-aligned output items.
2551+
See: https://openresponses.org/specification
2552+
"""
2553+
output_items = []
2554+
2555+
def next_id(prefix):
2556+
return f"{prefix}_{uuid4().hex[:24]}"
2557+
2558+
for block in content_blocks:
2559+
block_type = block.get("type", "")
2560+
# Use backend-provided ID if available, fallback to generated
2561+
block_id = block.get("id")
2562+
2563+
if block_type == "text":
2564+
text_content = block.get("content", "").strip()
2565+
if text_content:
2566+
output_items.append({
2567+
"type": "message",
2568+
"id": block_id or next_id("msg"),
2569+
"status": "completed",
2570+
"role": "assistant",
2571+
"content": [{"type": "output_text", "text": text_content}],
2572+
})
2573+
2574+
elif block_type == "tool_calls":
2575+
tool_calls = block.get("content", [])
2576+
results = block.get("results", [])
2577+
2578+
# Emit function_call items
2579+
for tool_call in tool_calls:
2580+
call_id = tool_call.get("id", "")
2581+
func = tool_call.get("function", {})
2582+
output_items.append({
2583+
"type": "function_call",
2584+
"id": call_id or next_id("fc"), # Use call_id as item id if available
2585+
"call_id": call_id,
2586+
"name": func.get("name", ""),
2587+
"arguments": func.get("arguments", "{}"),
2588+
"status": "completed" if results else "in_progress",
2589+
})
2590+
2591+
# Emit function_call_output items
2592+
for result in results:
2593+
output_items.append({
2594+
"type": "function_call_output",
2595+
"id": result.get("id") or next_id("fco"),
2596+
"call_id": result.get("tool_call_id", ""),
2597+
"output": [{"type": "input_text", "text": result.get("content", "")}],
2598+
"status": "completed",
2599+
**({"files": result.get("files")} if result.get("files") else {}),
2600+
**({"embeds": result.get("embeds")} if result.get("embeds") else {}),
2601+
})
2602+
2603+
elif block_type == "reasoning":
2604+
reasoning_content = block.get("content", "").strip()
2605+
duration = block.get("duration")
2606+
output_items.append({
2607+
"type": "reasoning",
2608+
"id": block_id or next_id("r"),
2609+
"status": "completed" if duration is not None else "in_progress",
2610+
"content": [{"type": "output_text", "text": reasoning_content}] if reasoning_content else None,
2611+
"summary": None,
2612+
})
2613+
2614+
elif block_type == "code_interpreter":
2615+
code = block.get("content", "")
2616+
output_val = block.get("output")
2617+
attrs = block.get("attributes", {})
2618+
output_items.append({
2619+
"type": "open_webui:code_interpreter",
2620+
"id": block_id or next_id("ci"),
2621+
"status": "completed" if output_val is not None else "in_progress",
2622+
"lang": attrs.get("lang", ""),
2623+
"code": code,
2624+
"output": output_val,
2625+
})
2626+
2627+
return output_items
2628+
25202629
def tag_content_handler(content_type, tags, content, content_blocks):
25212630
end_flag = False
25222631

@@ -3132,6 +3241,9 @@ async def flush_pending_delta_data(threshold: int = 0):
31323241
"content": serialize_content_blocks(
31333242
content_blocks
31343243
),
3244+
"output": convert_content_blocks_to_output(
3245+
content_blocks
3246+
),
31353247
},
31363248
)
31373249
else:
@@ -3221,6 +3333,7 @@ async def flush_pending_delta_data(threshold: int = 0):
32213333
"type": "chat:completion",
32223334
"data": {
32233335
"content": serialize_content_blocks(content_blocks),
3336+
"output": convert_content_blocks_to_output(content_blocks),
32243337
},
32253338
}
32263339
)
@@ -3400,6 +3513,7 @@ async def flush_pending_delta_data(threshold: int = 0):
34003513
"type": "chat:completion",
34013514
"data": {
34023515
"content": serialize_content_blocks(content_blocks),
3516+
"output": convert_content_blocks_to_output(content_blocks),
34033517
},
34043518
}
34053519
)
@@ -3446,6 +3560,7 @@ async def flush_pending_delta_data(threshold: int = 0):
34463560
"type": "chat:completion",
34473561
"data": {
34483562
"content": serialize_content_blocks(content_blocks),
3563+
"output": convert_content_blocks_to_output(content_blocks),
34493564
},
34503565
}
34513566
)
@@ -3577,6 +3692,7 @@ def restricted_import(name, globals=None, locals=None, fromlist=(), level=0):
35773692
"type": "chat:completion",
35783693
"data": {
35793694
"content": serialize_content_blocks(content_blocks),
3695+
"output": convert_content_blocks_to_output(content_blocks),
35803696
},
35813697
}
35823698
)
@@ -3616,6 +3732,7 @@ def restricted_import(name, globals=None, locals=None, fromlist=(), level=0):
36163732
data = {
36173733
"done": True,
36183734
"content": serialize_content_blocks(content_blocks),
3735+
"output": convert_content_blocks_to_output(content_blocks),
36193736
"title": title,
36203737
}
36213738

@@ -3626,6 +3743,7 @@ def restricted_import(name, globals=None, locals=None, fromlist=(), level=0):
36263743
metadata["message_id"],
36273744
{
36283745
"content": serialize_content_blocks(content_blocks),
3746+
"output": convert_content_blocks_to_output(content_blocks),
36293747
},
36303748
)
36313749

@@ -3664,6 +3782,7 @@ def restricted_import(name, globals=None, locals=None, fromlist=(), level=0):
36643782
metadata["message_id"],
36653783
{
36663784
"content": serialize_content_blocks(content_blocks),
3785+
"output": convert_content_blocks_to_output(content_blocks),
36673786
},
36683787
)
36693788

backend/open_webui/utils/misc.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,86 @@ def get_content_from_message(message: dict) -> Optional[str]:
128128
return None
129129

130130

131+
def convert_output_to_messages(output: list) -> list[dict]:
132+
"""
133+
Convert OR-aligned output items to OpenAI-format messages for LLM consumption.
134+
135+
This is the inverse of convert_content_blocks_to_output() in middleware.py.
136+
"""
137+
if not output or not isinstance(output, list):
138+
return []
139+
140+
messages = []
141+
pending_tool_calls = []
142+
pending_content = []
143+
144+
for item in output:
145+
item_type = item.get("type", "")
146+
147+
if item_type == "message":
148+
# Extract text from output_text content parts
149+
content_parts = item.get("content", [])
150+
text = ""
151+
for part in content_parts:
152+
if part.get("type") == "output_text":
153+
text += part.get("text", "")
154+
if text:
155+
pending_content.append(text)
156+
157+
elif item_type == "function_call":
158+
# Collect tool calls to batch into assistant message
159+
pending_tool_calls.append({
160+
"id": item.get("call_id", ""),
161+
"type": "function",
162+
"function": {
163+
"name": item.get("name", ""),
164+
"arguments": item.get("arguments", "{}"),
165+
}
166+
})
167+
168+
elif item_type == "function_call_output":
169+
# Flush any pending content/tool_calls before adding tool result
170+
if pending_content or pending_tool_calls:
171+
messages.append({
172+
"role": "assistant",
173+
"content": "\n".join(pending_content) if pending_content else "",
174+
**({"tool_calls": pending_tool_calls} if pending_tool_calls else {}),
175+
})
176+
pending_content = []
177+
pending_tool_calls = []
178+
179+
# Extract text from output content parts
180+
output_parts = item.get("output", [])
181+
content = ""
182+
for part in output_parts:
183+
if part.get("type") == "input_text":
184+
content += part.get("text", "")
185+
186+
messages.append({
187+
"role": "tool",
188+
"tool_call_id": item.get("call_id", ""),
189+
"content": content,
190+
})
191+
192+
elif item_type == "reasoning":
193+
# Skip reasoning blocks for LLM messages
194+
pass
195+
196+
elif item_type.startswith("open_webui:"):
197+
# Skip extension types
198+
pass
199+
200+
# Flush remaining content/tool_calls
201+
if pending_content or pending_tool_calls:
202+
messages.append({
203+
"role": "assistant",
204+
"content": "\n".join(pending_content) if pending_content else "",
205+
**({"tool_calls": pending_tool_calls} if pending_tool_calls else {}),
206+
})
207+
208+
return messages
209+
210+
131211
def get_last_user_message(messages: list[dict]) -> Optional[str]:
132212
message = get_last_user_message_item(messages)
133213
if message is None:

src/lib/components/chat/Chat.svelte

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1403,7 +1403,12 @@
14031403
};
14041404
14051405
const chatCompletionEventHandler = async (data, message, chatId) => {
1406-
const { id, done, choices, content, sources, selected_model_id, error, usage } = data;
1406+
const { id, done, choices, content, output, sources, selected_model_id, error, usage } = data;
1407+
1408+
// Store raw OR-aligned output items from backend
1409+
if (output) {
1410+
message.output = output;
1411+
}
14071412
14081413
if (error) {
14091414
await handleOpenAIError(error, message);
@@ -1899,7 +1904,9 @@
18991904
: undefined,
19001905
..._messages.map((message) => ({
19011906
...message,
1902-
content: processDetails(message.content)
1907+
content: processDetails(message.content),
1908+
// Include output for temp chats (backend will use it and strip before LLM)
1909+
...(message.output ? { output: message.output } : {})
19031910
}))
19041911
].filter((message) => message);
19051912

src/lib/components/workspace/Prompts.svelte

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,12 @@
1313
getPrompts,
1414
getPromptList
1515
} from '$lib/apis/prompts';
16-
import { capitalizeFirstLetter, slugify } from '$lib/utils';
16+
import { capitalizeFirstLetter, slugify, copyToClipboard } from '$lib/utils';
1717
1818
import PromptMenu from './Prompts/PromptMenu.svelte';
1919
import EllipsisHorizontal from '../icons/EllipsisHorizontal.svelte';
20+
import Clipboard from '../icons/Clipboard.svelte';
21+
import Check from '../icons/Check.svelte';
2022
import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
2123
import Search from '../icons/Search.svelte';
2224
import Plus from '../icons/Plus.svelte';
@@ -44,6 +46,7 @@
4446
4547
let tagsContainerElement: HTMLDivElement;
4648
let viewOption = '';
49+
let copiedId: string | null = null;
4750
4851
let filteredItems = [];
4952
@@ -105,6 +108,16 @@
105108
saveAs(blob, `prompt-export-${Date.now()}.json`);
106109
};
107110
111+
const copyHandler = async (prompt) => {
112+
const res = await copyToClipboard(prompt.content);
113+
if (res) {
114+
copiedId = prompt.command;
115+
setTimeout(() => {
116+
copiedId = null;
117+
}, 2000);
118+
}
119+
};
120+
108121
const deleteHandler = async (prompt) => {
109122
const command = prompt.command;
110123
@@ -370,6 +383,23 @@
370383
</button>
371384
</Tooltip>
372385
{:else}
386+
<Tooltip content={$i18n.t('Copy Prompt')}>
387+
<button
388+
class="self-center w-fit text-sm p-1.5 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
389+
type="button"
390+
on:click={(e) => {
391+
e.preventDefault();
392+
e.stopPropagation();
393+
copyHandler(prompt);
394+
}}
395+
>
396+
{#if copiedId === prompt.command}
397+
<Check className="size-4" strokeWidth="1.5" />
398+
{:else}
399+
<Clipboard className="size-4" strokeWidth="1.5" />
400+
{/if}
401+
</button>
402+
</Tooltip>
373403
<PromptMenu
374404
shareHandler={() => {
375405
shareHandler(prompt);

0 commit comments

Comments
 (0)