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
1 change: 1 addition & 0 deletions application/single_app/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -1112,6 +1112,7 @@ def add_security_headers(response):
if request_origin and request_origin in V2_UI_ALLOWED_ORIGINS:
response.headers['Access-Control-Allow-Origin'] = request_origin
response.headers['Access-Control-Allow-Credentials'] = 'true'
response.headers['Access-Control-Expose-Headers'] = 'Content-Disposition'
response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Accept'
response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, PATCH, DELETE, OPTIONS'
response.headers['Access-Control-Max-Age'] = '600'
Expand Down
2 changes: 1 addition & 1 deletion application/single_app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@
EXECUTOR_TYPE = 'thread'
EXECUTOR_MAX_WORKERS = 30
SESSION_TYPE = 'filesystem'
VERSION = "0.261.114"
VERSION = "0.261.115"
IS_DEVELOPMENT = is_development_env_enabled()

SESSION_COOKIE_SAMESITE = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax')
Expand Down
26 changes: 17 additions & 9 deletions application/single_app/functions_orchestration_planner.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
tries several strategies before giving up. A failed model call or invalid plan is an
error, not evidence that the task can be answered without gathering information.

Version: 0.261.104
Version: 0.261.115
"""

import json
Expand Down Expand Up @@ -768,10 +768,15 @@ def plan_request(
]
actions = context.get('actions') or []

def _failure(reason):
def _failure(reason, error=None, *, stage=None):
log_event(
'[ORCHESTRATION_PLANNER] The request could not be planned.',
level=logging.WARNING, extra={'reason': reason},
level=logging.WARNING, extra={
'reason': reason, 'stage': stage,
'conversation_id': conversation_id, 'turn_id': turn_id, 'revision': revision,
'error_type': type(error).__name__ if error is not None else None,
'response_failure': error.reason if isinstance(error, PlannerResponseError) else None,
},
)
raise PlannerError(
'The requested change could not be planned. Your previous plan is unchanged.'
Expand Down Expand Up @@ -805,8 +810,8 @@ def _failure(reason):
client, deployment = planner_model.as_planner_client(), planner_model.deployment
else:
client, deployment = resolve_planner_client(settings)
except (PlannerError, APIError, AzureError, ValueError):
return _failure('model_configuration_failed')
except (PlannerError, APIError, AzureError, ValueError) as exc:
return _failure('model_configuration_failed', exc, stage='model_binding')

try:
reply, usage = _call_planner(
Expand All @@ -815,8 +820,8 @@ def _failure(reason):
),
require_complete_response=True,
)
except (PlannerError, APIError, AzureError):
return _failure('model_request_failed')
except (PlannerError, APIError, AzureError) as exc:
return _failure('model_request_failed', exc, stage='model_request')

parsed = extract_planner_json(reply)
if not parsed:
Expand Down Expand Up @@ -927,9 +932,12 @@ def _failure(reason):
agent_names=agent_names,
actions=actions,
)
except PlanValidationError as exc:
return _failure('invalid_plan_or_missing_requirement', exc, stage='plan_normalization')
try:
validate_plan_requirements(plan, seeds, allow_changes=edit_context is not None)
except PlanValidationError:
return _failure('invalid_plan_or_missing_requirement')
except PlanValidationError as exc:
return _failure('invalid_plan_or_missing_requirement', exc, stage='selected_requirements')

if plan.get('validation', {}).get('errors'):
return _failure('invalid_plan_work')
Expand Down
83 changes: 64 additions & 19 deletions application/single_app/route_enhanced_citations.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from flask import jsonify, request, Response
from datetime import datetime, timedelta, timezone
import hashlib
import logging
import os
import tempfile
Expand All @@ -14,6 +15,7 @@
import pandas
import fitz
from azure.cosmos.exceptions import CosmosResourceNotFoundError
from azure.core.exceptions import AzureError, ResourceNotFoundError
from werkzeug.utils import secure_filename

from content_screening.access import (
Expand All @@ -38,6 +40,7 @@
from functions_saved_analysis import authorize_analysis_artifact
from functions_simplechat_operations import (
assert_generated_chat_artifact_is_published_for_user,
download_blob_content,
)
from swagger_wrapper import swagger_route, get_auth_security
from config import CLIENTS, storage_account_user_documents_container_name, storage_account_group_documents_container_name, storage_account_public_documents_container_name, storage_account_personal_chat_container_name, IMAGE_EXTENSIONS, VIDEO_EXTENSIONS, AUDIO_EXTENSIONS, TABULAR_EXTENSIONS, VISIO_EXTENSIONS, cosmos_messages_container, cosmos_conversations_container
Expand Down Expand Up @@ -84,7 +87,12 @@ def _get_authorized_chat_artifact_message(user_id, conversation_id, message_id):
# unreachable for every caller, including the participant who requested it.
assert_generated_file_approval_allows_download(user_id, message_item)
assert_generated_chat_artifact_is_published_for_user(user_id, message_item)
assert_evidence_available(message_item, user_id)
# A workspace link names the active representation; its retained chat blob is not the source to read.
evidence = {
key: value for key, value in message_item.items()
if not message_item.get("workspace_document_id") or key not in {"blob_container", "blob_path"}
}
assert_evidence_available(evidence, user_id)
return message_item


Expand Down Expand Up @@ -204,6 +212,39 @@ def _build_content_disposition(disposition, file_name, fallback='download'):
return f'{normalized_disposition}; filename="{ascii_file_name}"; filename*=UTF-8\'\'{encoded_file_name}'


def _serve_chat_artifact_download(user_id, conversation_id, message_id):
"""Read an authorized chat artifact, not a workspace document's representation."""
artifact = _get_authorized_chat_artifact_message(user_id, conversation_id, message_id)
active_document = None
if artifact.get('workspace_document_id'):
active_document, content = read_available_document_bytes(
artifact['workspace_document_id'], user_id=user_id, purpose='chat_file',
)
else:
content = download_blob_content(artifact['blob_container'], artifact['blob_path'])
current = _get_authorized_chat_artifact_message(user_id, conversation_id, message_id)
identity_fields = ('id', 'conversation_id', 'workspace_document_id', 'blob_container', 'blob_path', 'filename', '_etag')
digest = (artifact.get('metadata') or {}).get('generated_artifact_content_sha256')
current_digest = (current.get('metadata') or {}).get('generated_artifact_content_sha256')
if any(artifact.get(field) != current.get(field) for field in identity_fields) or digest != current_digest:
raise LookupError('The artifact changed during download.')
if active_document is None and digest and hashlib.sha256(content).hexdigest() != digest:
raise LookupError('The artifact content changed.')

file_name = (active_document or {}).get('file_name') or _resolve_generated_artifact_file_name(current)
content_type = {
'.csv': 'text/csv; charset=utf-8',
'.md': 'text/markdown; charset=utf-8',
'.json': 'application/json',
}.get(os.path.splitext(file_name)[1].lower()) or mimetypes.guess_type(file_name)[0] or 'application/octet-stream'
return Response(content, content_type=content_type, headers={
'Content-Length': str(len(content)),
'Content-Disposition': _build_content_disposition('attachment', file_name),
'Cache-Control': 'private, no-store',
'X-Content-Type-Options': 'nosniff',
})


def _log_enhanced_citations_debug(message, **details):
"""Write debug-gated enhanced citations diagnostics."""
log_event(
Expand Down Expand Up @@ -643,25 +684,29 @@ def download_chat_artifact():
return jsonify({"error": "User not authenticated"}), 401

try:
message_item = _get_authorized_chat_artifact_message(user_id, conversation_id, message_id)
return serve_enhanced_citation_content(
{
'file_name': _resolve_generated_artifact_file_name(message_item),
'blob_container': message_item.get('blob_container'),
'blob_path': message_item.get('blob_path'),
},
force_download=True,
return _serve_chat_artifact_download(user_id, conversation_id, message_id)
except ScreeningError as exc:
return jsonify({"error": exc.public_message, "error_code": exc.code}), exc.status_code
except PermissionError:
return jsonify({"error": "You no longer have access to this artifact or its sources."}), 403
except (LookupError, ResourceNotFoundError):
return jsonify({"error": "The artifact content is unavailable. Refresh the conversation and try again."}), 404
except ValueError:
return jsonify({"error": "Invalid artifact download request."}), 400
except (AzureError, RuntimeError) as exc:
log_event(
"[ENHANCED_CITATIONS] Chat artifact storage is unavailable.",
extra={"error_type": type(exc).__name__, "conversation_id": conversation_id, "message_id": message_id},
level=logging.ERROR,
)
except PermissionError as exc:
debug_print(f"Forbidden chat artifact download attempt: {exc}")
return jsonify({"error": "Forbidden"}), 403
except LookupError as exc:
return jsonify({"error": str(exc)}), 404
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
except Exception as e:
debug_print(f"Error serving chat artifact download: {e}")
return jsonify({"error": "An internal error has occurred"}), 500
return jsonify({"error": "Artifact storage is temporarily unavailable. Please retry the download."}), 503
except Exception as exc:
log_event(
"[ENHANCED_CITATIONS] Chat artifact download failed.",
extra={"error_type": type(exc).__name__, "conversation_id": conversation_id, "message_id": message_id},
level=logging.ERROR,
)
return jsonify({"error": "The artifact could not be downloaded. Please retry."}), 500

@bp.route("/api/chat_artifacts/promote", methods=["POST"])
@swagger_route(security=get_auth_security())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -518,7 +518,7 @@ export function showAudioModal(docId, timestamp, fileName) {
modalInstance.show();
}

function triggerBlobDownload(blob, filename) {
export function triggerBlobDownload(blob, filename) {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
Expand All @@ -529,7 +529,7 @@ function triggerBlobDownload(blob, filename) {
window.setTimeout(() => URL.revokeObjectURL(url), 0);
}

function getDownloadFilename(response, fallbackFilename) {
export function getDownloadFilename(response, fallbackFilename) {
const contentDisposition = response.headers.get('Content-Disposition') || '';
const utf8Match = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i);
if (utf8Match && utf8Match[1]) {
Expand Down
32 changes: 23 additions & 9 deletions application/single_app/static/js/chat/chat-messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { updateSidebarConversationTitle } from "./chat-sidebar-conversations.js"
import { getActiveConversationContext, getActiveConversationScope } from "./chat-conversation-scope.js";
import { escapeHtml, isColorLight, addTargetBlankToExternalLinks, sanitizeHttpUrl } from "./chat-utils.js";
import { showToast } from "./chat-toast.js";
import { getDownloadFilename, triggerBlobDownload } from "./chat-enhanced-citations.js";
import {
buildGeneratedFileApprovalBlock,
generatedFileApprovalBlocksDownload,
Expand Down Expand Up @@ -4441,21 +4442,34 @@ function renderReplyQuoteHtml(fullMessageObject = null) {
return '';
}

function triggerGeneratedTabularOutputDownload(outputMetadata) {
async function triggerGeneratedTabularOutputDownload(outputMetadata, downloadButton) {
const downloadHref = buildGeneratedArtifactDownloadUrl(outputMetadata);

if (!downloadHref) {
showToast('Generated export is missing download metadata.', 'warning');
return;
}

const downloadLink = document.createElement('a');
downloadLink.href = downloadHref;
downloadLink.rel = 'noopener';
downloadLink.className = 'd-none';
document.body.appendChild(downloadLink);
downloadLink.click();
downloadLink.remove();
if (downloadButton.disabled) {
return;
}
const originalText = downloadButton.textContent;
downloadButton.disabled = true;
downloadButton.textContent = 'Downloading...';
try {
const response = await fetch(downloadHref, { credentials: 'same-origin', cache: 'no-store' });
const disposition = response.headers.get('Content-Disposition') || '';
if (!response.ok || response.redirected || !/^\s*attachment(?:;|$)/i.test(disposition)) {
throw new Error('The artifact could not be downloaded.');
}
const blob = await response.blob();
triggerBlobDownload(blob, getDownloadFilename(response, outputMetadata.file_name || 'generated-artifact'));
} catch {
showToast('The artifact could not be downloaded. Refresh the conversation and try again.', 'danger');
} finally {
downloadButton.disabled = false;
downloadButton.textContent = originalText;
}
}

async function viewGeneratedMarkdownArtifact(outputMetadata, viewButton) {
Expand Down Expand Up @@ -5579,7 +5593,7 @@ function renderReplyQuoteHtml(fullMessageObject = null) {
memberCount: 1,
formats: [outputFormat],
});
triggerGeneratedTabularOutputDownload(outputMetadata);
void triggerGeneratedTabularOutputDownload(outputMetadata, downloadButton);
});
actions.appendChild(downloadButton);

Expand Down
7 changes: 2 additions & 5 deletions application/v2_ui/src/components/chat/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -881,7 +881,8 @@ export function Composer({ initialAgentSelection }: { initialAgentSelection?: st
const seeds: Record<string, unknown> = {
web_search_enabled: options.webSearch,
required_capabilities: [
...(options.documentSearch || contextItems.length > 0 ? ['document_search'] : []),
// Pinned sources constrain inputs, not the operation (search, Analyze, or Compare).
...(options.documentSearch ? ['document_search'] : []),
...(options.webSearch ? ['web_search'] : []),
...(options.deepResearch ? ['deep_research'] : []),
...(options.urlAccess && promptUrls(message).length > 0 ? ['url_fetch'] : []),
Expand Down Expand Up @@ -991,10 +992,6 @@ export function Composer({ initialAgentSelection }: { initialAgentSelection?: st
return;
}
handoffApplied.current = true;
setOptions((current) => ({
...current,
documentSearch: items.length > 0 || current.documentSearch,
}));
setDraft((current) => ({
...current,
contextItems: items.reduce(
Expand Down
6 changes: 3 additions & 3 deletions application/v2_ui/src/components/chat/ConversationDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -261,13 +261,13 @@ export function ConversationDrawer() {
return (
<aside
aria-label="Conversation details"
className="glass glass-edge flex w-[22rem] shrink-0 flex-col rounded-none border-t-0 border-r-0 border-b-0"
className="glass glass-edge absolute inset-y-0 right-0 z-30 flex w-[22rem] max-w-full shrink-0 flex-col rounded-none border-t-0 border-r-0 border-b-0 xl:static xl:z-auto"
>
<div className="flex h-14 shrink-0 items-center gap-2 border-b border-edge px-3">
<div className="flex min-h-14 shrink-0 flex-wrap items-center gap-2 border-b border-edge px-3 py-2">
<div
role="tablist"
aria-label="Drawer mode"
className="flex gap-1 rounded-xl bg-surface-sunken p-1"
className="flex min-w-0 flex-wrap gap-1 rounded-xl bg-surface-sunken p-1"
>
{tabs.map((tab) => (
<button
Expand Down
30 changes: 18 additions & 12 deletions application/v2_ui/src/components/chat/GeneratedArtifactCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { clsx } from 'clsx';
import { Download, Eye, FileLock2, Loader2, X } from 'lucide-react';
import { generatedArtifactDownloadUrl } from '../../lib/endpoints';
import { downloadGeneratedArtifact, generatedArtifactDownloadUrl } from '../../lib/endpoints';
import { resolveGeneratedFileApproval } from '../../lib/collaboration';
import { toast } from '../../stores/toastStore';
import { GlassButton, GlassPanel } from '../ui/primitives';
Expand Down Expand Up @@ -265,6 +265,7 @@ export function GeneratedArtifactCard({
const [finished, setFinished] = useState<GeneratedArtifact[] | null>(null);
const [previewOpen, setPreviewOpen] = useState(false);
const [deciding, setDeciding] = useState<'approve' | 'deny' | null>(null);
const [downloading, setDownloading] = useState(false);

// A completed run replaces its own progress card with the files it produced.
if (finished) {
Expand Down Expand Up @@ -354,17 +355,22 @@ export function GeneratedArtifactCard({
}
};

const download = () => {
const download = async () => {
if (downloading) {
return;
}
if (!downloadUrl) {
toast.error('Generated export is missing download metadata.');
return;
}
const link = document.createElement('a');
link.href = downloadUrl;
link.rel = 'noopener';
document.body.appendChild(link);
link.click();
link.remove();
setDownloading(true);
try {
await downloadGeneratedArtifact(artifact, conversationId);
} catch {
toast.error('The artifact could not be downloaded. Refresh the conversation and try again.');
} finally {
setDownloading(false);
}
};

// While a run is in flight the supporting notes move inside its collapsed details, so the
Expand All @@ -386,7 +392,7 @@ export function GeneratedArtifactCard({
);

return (
<section className="glass-flat mt-3 rounded-xl p-3">
<section className="glass-flat mt-3 min-w-0 rounded-xl p-3 [overflow-wrap:anywhere]">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<h4 className="text-sm font-semibold text-text-1">{artifactTitle(artifact)}</h4>
Expand Down Expand Up @@ -491,9 +497,9 @@ export function GeneratedArtifactCard({

{!running && !withheld && downloadUrl && (
<div className="mt-3 flex flex-wrap gap-2">
<GlassButton size="sm" variant="subtle" onClick={download}>
<Download size={13} />
Download {outputFormat.toUpperCase()}
<GlassButton size="sm" variant="subtle" onClick={() => void download()} disabled={downloading}>
{downloading ? <Loader2 size={13} className="animate-spin" /> : <Download size={13} />}
{downloading ? 'Downloading' : 'Download'} {outputFormat.toUpperCase()}
</GlassButton>

{compact && hasArtifactPreview(artifact) && (
Expand Down
Loading