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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions application/single_app/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ psycopg2-binary==2.9.10
cython==3.2.4
pyyaml==6.0.2
aiohttp==3.14.3
# AnyIO 4.11+ requires typing_extensions.sentinel, which older App Service agents can shadow.
anyio==4.10.0
html2text==2025.4.15
matplotlib==3.10.7
azure-cognitiveservices-speech==1.48.2
Expand Down
42 changes: 39 additions & 3 deletions application/single_app/route_backend_conversations.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,41 @@ def _build_replayed_document_context(original_metadata):
'active_public_workspace_ids': workspace_search.get('active_public_workspace_ids') or [],
}

def _build_replayed_model_context(original_metadata, request_data=None):
"""Rebuild model endpoint routing from stored metadata for retry and edit."""
metadata = original_metadata if isinstance(original_metadata, dict) else {}
model_selection = metadata.get('model_selection')
model_selection = model_selection if isinstance(model_selection, dict) else {}
request_data = request_data if isinstance(request_data, dict) else {}

stored_request_model = str(
model_selection.get('frontend_requested_model')
or model_selection.get('selected_model')
or ''
).strip()
stored_selected_model = str(model_selection.get('selected_model') or '').strip()
requested_model = str(request_data.get('model') or '').strip()
replayed_model = requested_model or stored_request_model
reuses_stored_selection = not requested_model or requested_model in {
stored_request_model,
stored_selected_model,
}

def requested_or_stored(request_key, metadata_key):
requested_value = request_data.get(request_key)
if requested_value not in (None, ''):
return requested_value
if reuses_stored_selection:
return model_selection.get(metadata_key)
return None

return {
'model_deployment': replayed_model or None,
'model_id': requested_or_stored('model_id', 'model_id'),
'model_endpoint_id': requested_or_stored('model_endpoint_id', 'model_endpoint_id'),
'model_provider': requested_or_stored('model_provider', 'model_provider'),
'model_icon': requested_or_stored('model_icon', 'model_icon'),
}

def _get_requested_workspace_document_delete_ids_for_conversation(payload, conversation_id):
if not isinstance(payload, dict):
Expand Down Expand Up @@ -2758,7 +2793,6 @@ def retry_message(message_id):

try:
data = request.get_json() or {}
selected_model = data.get('model')
reasoning_effort = data.get('reasoning_effort')
agent_info = data.get('agent_info') # Get agent info if provided

Expand Down Expand Up @@ -2915,10 +2949,11 @@ def retry_message(message_id):
)
# Build chat request parameters from original message metadata
replayed_document_context = _build_replayed_document_context(original_metadata)
replayed_model_context = _build_replayed_model_context(original_metadata, data)
chat_request = {
'message': user_content,
'conversation_id': conversation_id,
'model_deployment': selected_model or original_metadata.get('model_selection', {}).get('selected_model'),
**replayed_model_context,
'reasoning_effort': reasoning_effort or original_metadata.get('reasoning_effort'),
**replayed_document_context,
'image_generation': original_metadata.get('image_generation', {}).get('enabled', False),
Expand Down Expand Up @@ -3139,10 +3174,11 @@ def edit_message(message_id):
# Build chat request parameters from original message metadata
# Keep all original settings (model, reasoning, doc search, etc.)
replayed_document_context = _build_replayed_document_context(original_metadata)
replayed_model_context = _build_replayed_model_context(original_metadata)
chat_request = {
'message': edited_content, # Use edited content
'conversation_id': conversation_id,
'model_deployment': original_metadata.get('model_selection', {}).get('selected_model'),
**replayed_model_context,
'reasoning_effort': original_metadata.get('reasoning_effort'),
**replayed_document_context,
'image_generation': original_metadata.get('image_generation', {}).get('enabled', False),
Expand Down
12 changes: 6 additions & 6 deletions application/single_app/route_backend_retention_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -410,7 +410,7 @@ def update_user_retention_settings():
# Validate and parse conversation retention
if 'conversation_retention_days' in data:
conv_retention = data['conversation_retention_days']
if conv_retention == 'none' or conv_retention is None:
if conv_retention == 'none' or conv_retention == 'default' or conv_retention is None:
retention_settings['conversation_retention_days'] = 'none'
else:
try:
Expand All @@ -435,7 +435,7 @@ def update_user_retention_settings():
# Validate and parse document retention
if 'document_retention_days' in data:
doc_retention = data['document_retention_days']
if doc_retention == 'none' or doc_retention is None:
if doc_retention == 'none' or doc_retention == 'default' or doc_retention is None:
retention_settings['document_retention_days'] = 'none'
else:
try:
Expand Down Expand Up @@ -519,7 +519,7 @@ def update_group_retention_settings(group_id):
# Validate and parse conversation retention
if 'conversation_retention_days' in data:
conv_retention = data['conversation_retention_days']
if conv_retention == 'none' or conv_retention is None:
if conv_retention == 'none' or conv_retention == 'default' or conv_retention is None:
retention_settings['conversation_retention_days'] = 'none'
else:
try:
Expand All @@ -544,7 +544,7 @@ def update_group_retention_settings(group_id):
# Validate and parse document retention
if 'document_retention_days' in data:
doc_retention = data['document_retention_days']
if doc_retention == 'none' or doc_retention is None:
if doc_retention == 'none' or doc_retention == 'default' or doc_retention is None:
retention_settings['document_retention_days'] = 'none'
else:
try:
Expand Down Expand Up @@ -629,7 +629,7 @@ def update_public_workspace_retention_settings(public_workspace_id):
# Validate and parse conversation retention
if 'conversation_retention_days' in data:
conv_retention = data['conversation_retention_days']
if conv_retention == 'none' or conv_retention is None:
if conv_retention == 'none' or conv_retention == 'default' or conv_retention is None:
retention_settings['conversation_retention_days'] = 'none'
else:
try:
Expand All @@ -654,7 +654,7 @@ def update_public_workspace_retention_settings(public_workspace_id):
# Validate and parse document retention
if 'document_retention_days' in data:
doc_retention = data['document_retention_days']
if doc_retention == 'none' or doc_retention is None:
if doc_retention == 'none' or doc_retention == 'default' or doc_retention is None:
retention_settings['document_retention_days'] = 'none'
else:
try:
Expand Down
11 changes: 7 additions & 4 deletions application/single_app/static/css/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -124,18 +124,21 @@ main {
color: #e9ecef !important;
}

/* Fix for group dropdown in dark mode */
[data-bs-theme="dark"] #group-dropdown .group-search-container {
/* Fix for workspace selector dropdowns in dark mode */
[data-bs-theme="dark"] #group-dropdown .group-search-container,
[data-bs-theme="dark"] #public-dropdown .public-search-container {
background-color: #343a40 !important;
border-bottom: 1px solid #495057 !important;
}

[data-bs-theme="dark"] #group-dropdown .dropdown-item:hover {
[data-bs-theme="dark"] #group-dropdown .dropdown-item:hover,
[data-bs-theme="dark"] #public-dropdown .dropdown-item:hover {
background-color: #495057 !important;
color: #e9ecef !important;
}

[data-bs-theme="dark"] #group-dropdown .dropdown-menu {
[data-bs-theme="dark"] #group-dropdown .dropdown-menu,
[data-bs-theme="dark"] #public-dropdown .dropdown-menu {
background-color: #343a40 !important;
color: #e9ecef !important;
border-color: #495057 !important;
Expand Down
29 changes: 25 additions & 4 deletions application/single_app/static/js/chat/chat-input-actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,18 @@ function buildUploadFileList(filesLike, fallbackPrefix = "clipboard_upload") {
.filter(Boolean);
}

async function ensureUploadFileIsReadable(file) {
const fileName = String(file?.name || "selected file").trim() || "selected file";

try {
await file.slice(0, Math.min(file.size, 1)).arrayBuffer();
} catch (error) {
throw new Error(
`The browser could not read "${fileName}". Close the file in other apps then try again.`
);
}
}

function hasNamedFile(files) {
return Array.from(files || []).some((file) => String(file?.name || "").trim());
}
Expand Down Expand Up @@ -684,6 +696,7 @@ export async function uploadFileToConversation(file) {
let uploadingIndicatorEl = null;

try {
await ensureUploadFileIsReadable(file);
const groupUploadContext = await resolveGroupUploadContext();
uploadingIndicatorEl = showFileUploadingMessage();

Expand All @@ -706,10 +719,18 @@ export async function uploadFileToConversation(file) {
});
}

const response = await fetch("/upload", {
method: "POST",
body: formData,
});
let response;
try {
response = await fetch("/upload", {
method: "POST",
body: formData,
});
} catch (error) {
const fileName = String(file?.name || "selected file").trim() || "selected file";
throw new Error(
`The browser could not upload "${fileName}". The file may be open in another app, unavailable from cloud storage, or the network connection may have been interrupted. Ensure the file is closed and then try again.`
);
}

hideFileUploadingMessage(uploadingIndicatorEl);
uploadingIndicatorEl = null;
Expand Down
43 changes: 33 additions & 10 deletions application/single_app/static/js/chat/chat-retry.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,8 @@ export async function handleRetryButtonClick(messageDiv, messageId, messageType)

if (modelSelect && retryModelSelect) {
// Clone model options from main select
retryModelSelect.innerHTML = modelSelect.innerHTML;
const clonedOptions = Array.from(modelSelect.options || []).map((option) => option.cloneNode(true));
retryModelSelect.replaceChildren(...clonedOptions);
retryModelSelect.value = modelSelect.value; // Set to currently selected model
}

Expand Down Expand Up @@ -188,14 +189,26 @@ export async function handleRetryButtonClick(messageDiv, messageId, messageType)
levels.forEach(level => {
const div = document.createElement('div');
div.className = 'form-check';
div.innerHTML = `
<input class="form-check-input" type="radio" name="retry-reasoning-effort"
id="retry-reasoning-${level.value}" value="${level.value}"
${level.value === 'medium' ? 'checked' : ''}>
<label class="form-check-label" for="retry-reasoning-${level.value}">
<strong>${level.label}</strong> - ${level.description}
</label>
`;

const input = document.createElement('input');
input.className = 'form-check-input';
input.type = 'radio';
input.name = 'retry-reasoning-effort';
input.id = `retry-reasoning-${level.value}`;
input.value = level.value;
input.checked = level.value === 'medium';

const label = document.createElement('label');
label.className = 'form-check-label';
label.setAttribute('for', input.id);

const strong = document.createElement('strong');
strong.textContent = level.label;

label.appendChild(strong);
label.appendChild(document.createTextNode(` - ${level.description}`));
div.appendChild(input);
div.appendChild(label);
retryReasoningLevels.appendChild(div);
});
}
Expand Down Expand Up @@ -264,8 +277,18 @@ window.executeMessageRetry = function() {
// Model mode - get model and reasoning effort
const retryModelSelect = document.getElementById('retry-model-select');
const selectedOption = retryModelSelect ? retryModelSelect.options[retryModelSelect.selectedIndex] : null;
const selectedModel = selectedOption?.dataset?.deploymentName || (retryModelSelect ? retryModelSelect.value : null);
const selectedModel = selectedOption?.dataset?.requestModel
|| selectedOption?.dataset?.deploymentName
|| (retryModelSelect ? retryModelSelect.value : null);
requestBody.model = selectedModel;
requestBody.model_id = selectedOption?.dataset?.modelId || null;
requestBody.model_endpoint_id = selectedOption?.dataset?.endpointId || null;
requestBody.model_provider = selectedOption?.dataset?.provider || null;
try {
requestBody.model_icon = JSON.parse(selectedOption?.dataset?.modelIcon || '{}');
} catch (error) {
requestBody.model_icon = {};
}

let reasoningEffort = null;
const retryReasoningContainer = document.getElementById('retry-reasoning-container');
Expand Down
2 changes: 1 addition & 1 deletion application/single_app/templates/public_workspaces.html
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ <h3 id="active-public-name" style="display:none"></h3>

{% include "_semantic_search_health_warning.html" %}

<div class="row mb-3 align-items-center g-2" id="public-selector-row">
<div class="row mb-3 align-items-center g-2 workspace-toolbar-row" id="public-selector-row">
<div class="col-12 col-lg">
<div class="dropdown" id="public-dropdown">
<button class="form-select d-flex justify-content-between align-items-center" id="public-dropdown-button" data-bs-toggle="dropdown">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Chat Retry and Edit Model Endpoint Replay Fix

Retrying a response or editing a prompt could fail before receiving content even though the original request succeeded. The replay request restored only the saved model name, so multi-endpoint models could be sent to the default Azure OpenAI resource instead of the endpoint that handled the original request.

Fixed/Implemented in version: **0.261.033**

Related config.py update: `VERSION = "0.261.033"`

## Root Cause

Initial chat sends include the request model, model ID, endpoint ID, and provider. Retry and edit preparation rebuilt `model_deployment` from `metadata.model_selection.selected_model` but discarded the other routing fields. Without `model_endpoint_id`, streaming model resolution could fall through to the default Azure OpenAI client and use a model identifier as an Azure deployment name, resulting in `DeploymentNotFound`.

## Technical Details

Files modified:

- `application/single_app/route_backend_conversations.py`
- `application/single_app/static/js/chat/chat-retry.js`
- `functional_tests/test_chat_retry_edit_model_endpoint_replay.py`
- `application/single_app/config.py`

The backend now rebuilds the complete model routing context from the original message metadata for retry and edit. The retry modal also sends the selected option's request model, model ID, endpoint ID, provider, and icon, matching the initial chat request contract. A retry that explicitly selects another model does not inherit stale endpoint metadata.

## Validation

Regression coverage verifies that:

- Edit restores the original request model and endpoint identity.
- Retrying the same model preserves the saved endpoint identity.
- Selecting a different model does not retain stale endpoint fields.
- The retry frontend sends the complete selected model identity.

The Python route compiles successfully and the retry JavaScript passes `node --check`.

## Impact

Retry and edit requests for multi-endpoint models are routed to the same configured endpoint as the original response, avoiding false Azure OpenAI `DeploymentNotFound` failures before streaming begins.
39 changes: 39 additions & 0 deletions docs/explanation/fixes/CHAT_UPLOAD_FILE_ACCESS_ERROR_FIX.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Chat Upload File Access Error Fix

Fixed in version: **0.261.032**

## Issue

Dragging a file into chat could report only `Failed to fetch` when the browser
could not read the file. This commonly affected cloud-synced documents that
were open in another application or were not fully available on the device.
Because the browser failed while preparing the request body, the upload never
reached SimpleChat and no corresponding server log entry was created.

## Root Cause

The chat uploader passed the selected `File` directly to `fetch()`. Browser
file-access failures surfaced as a generic network `TypeError`, which did not
identify the document or explain how the user could resolve the problem.

## Technical Details

The shared chat upload path now performs a minimal file-read check before
starting the request. If that check fails, the toast identifies the filename
and asks the user to close it in other applications or make it available
offline. A later browser transport failure also explains the possible file
lock, cloud availability, and network causes instead of displaying only the
opaque browser error.

The change applies consistently to files selected with the paperclip, pasted
from the clipboard, or dropped into the chat input.

## Validation

The existing chat upload Playwright workflow now simulates an unreadable
dropped file and verifies that the actionable toast appears without an upload
request. Functional coverage also checks that the shared uploader retains the
file-read guard and recovery guidance.

The application version in `application/single_app/config.py` was updated to
`0.261.032` for this fix.
15 changes: 15 additions & 0 deletions docs/explanation/release_notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,21 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Ver

#### Bug Fixes

* **Chat Upload File Access Errors Avoid Browser-Realm Checks**
* Hardened chat upload recovery messaging so rejected browser upload requests no longer depend on a realm-specific `TypeError` check.
* Fetch preparation and transport failures now consistently identify the selected file and show the same recovery guidance for locked files, cloud-placeholder files, and interrupted local upload requests.
* Also removed unsafe HTML-string construction from the retry modal's model-option cloning and reasoning-level controls.
* (Ref: chat upload file access handling, retry modal rendering, `chat-input-actions.js`, `chat-retry.js`, `test_chat_clipboard_paste_upload_support.py`)

### **(v0.261.045)**

#### Bug Fixes

* **Public Workspace Selector Dark Theme Styling**
* Fixed the public workspace selector dropdown so its dark-theme background, border, search area, hover state, and active item styling match the group workspace selector.
* Added the shared workspace selector row class to the public selector row and extended the existing group dropdown dark-theme rules to the public dropdown.
* (Ref: public workspace selector, dark theme dropdown styling, `public_workspaces.html`, `styles.css`)

* **Revert Premature OneNote Rollout From Development**
* Removed native `.one` and `.onepkg` workspace uploads from Development pending Reactv2 testing, correcting the accidental merge of PR #1525.
* Restored the previous upload formats and container packaging. Existing document formats and the separately adapted Reactv2 OneNote implementation remain unchanged.
Expand Down
Loading