diff --git a/application/single_app/config.py b/application/single_app/config.py index 3f693e9b4..85f9d9a22 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -101,7 +101,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.280" +VERSION = "0.261.281" IS_DEVELOPMENT = is_development_env_enabled() # Opt-out for deployments where App Service Easy Auth is active but the platform diff --git a/application/single_app/route_backend_feedback.py b/application/single_app/route_backend_feedback.py index 790adefcb..98c3a6bcb 100644 --- a/application/single_app/route_backend_feedback.py +++ b/application/single_app/route_backend_feedback.py @@ -708,7 +708,7 @@ def feedback_my(): try: page = int(request.args.get('page', 1)) page_size = int(request.args.get('page_size', 10)) - filter_type, filter_ack_bool = _parse_feedback_filters() + filter_type, filter_ack_bool, _ = _parse_feedback_filters() items = _query_feedback_items( user_id=user_id, filter_type=filter_type, @@ -739,7 +739,7 @@ def feedback_my_stats(): return jsonify({"error": "No user ID found in session"}), 403 try: - filter_type, filter_ack_bool = _parse_feedback_filters() + filter_type, filter_ack_bool, _ = _parse_feedback_filters() items = _query_feedback_items( user_id=user_id, filter_type=filter_type, @@ -761,7 +761,7 @@ def feedback_my_export(): return jsonify({"error": "No user ID found in session"}), 403 try: - filter_type, filter_ack_bool = _parse_feedback_filters() + filter_type, filter_ack_bool, _ = _parse_feedback_filters() items = _query_feedback_items( user_id=user_id, filter_type=filter_type, diff --git a/application/single_app/route_backend_retention_policy.py b/application/single_app/route_backend_retention_policy.py index 6fd0afa96..b3969a8f7 100644 --- a/application/single_app/route_backend_retention_policy.py +++ b/application/single_app/route_backend_retention_policy.py @@ -412,8 +412,8 @@ def update_user_retention_settings(): Update retention policy settings for the current user's personal workspace. Body: - conversation_retention_days (str|int): Number of days or 'none' - document_retention_days (str|int): Number of days or 'none' + conversation_retention_days (str|int): Number of days, 'none' or 'default' + document_retention_days (str|int): Number of days, 'none' or 'default' """ try: user_id = get_current_user_id() @@ -426,6 +426,8 @@ def update_user_retention_settings(): conv_retention = data['conversation_retention_days'] if conv_retention == 'none' or conv_retention is None: retention_settings['conversation_retention_days'] = 'none' + elif conv_retention == 'default': + retention_settings['conversation_retention_days'] = 'default' else: try: days = int(conv_retention) @@ -451,6 +453,8 @@ def update_user_retention_settings(): doc_retention = data['document_retention_days'] if doc_retention == 'none' or doc_retention is None: retention_settings['document_retention_days'] = 'none' + elif doc_retention == 'default': + retention_settings['document_retention_days'] = 'default' else: try: days = int(doc_retention) diff --git a/application/single_app/route_backend_users.py b/application/single_app/route_backend_users.py index 00a3657c6..327106c59 100644 --- a/application/single_app/route_backend_users.py +++ b/application/single_app/route_backend_users.py @@ -47,6 +47,9 @@ MEMBERSHIP_STATUS_PENDING, } +# Guided tours the new interface ships. Must match the ids in v2_ui/src/lib/tours.ts. +TUTORIAL_IDS = frozenset({"chat", "workspace"}) + # /api/userSearch waits at most this long for Graph, the bound the SimpleChat # operations' Graph calls use. USER_SEARCH_GRAPH_TIMEOUT_SECONDS = 20 @@ -560,6 +563,8 @@ def user_settings(): # Whether the V2 Admin Settings categories rail is collapsed to icons. # Separate from the shell and workspace rails so each keeps its own state. 'v2AdminRailCollapsed', + # Whether the V2 User Settings sections rail is collapsed to icons. + 'v2UserSettingsRailCollapsed', # Whether the V2 Control Center section rail is collapsed to icons. 'v2ControlCenterRailCollapsed', # V2 document explorer: how the workspace documents list is presented @@ -578,7 +583,7 @@ def user_settings(): 'chatCompletionAudioEnabled', 'chatCompletionAudioMuted', 'chatCompletionAudioSound', 'chatCompletionAudioVolume', # Tutorial visibility settings - 'showTutorialButtons', + 'showTutorialButtons', 'tutorialVisibility', # Desktop conversation notification settings 'desktopNotificationsEnabled', 'recentCollaborators', @@ -688,6 +693,22 @@ def user_settings(): return jsonify({"error": "Completion audio volume must be between 1 and 10"}), 400 settings_to_update["chatCompletionAudioVolume"] = volume + if "showTutorialButtons" in settings_to_update: + if not isinstance(settings_to_update["showTutorialButtons"], bool): + return jsonify({"error": "Invalid tutorial button preference"}), 400 + + # Per-tutorial visibility from the new interface, { tutorialId: bool }. Only + # the tutorials the application ships are accepted, so the map cannot grow + # without bound or carry arbitrary keys into the settings document. + if "tutorialVisibility" in settings_to_update: + tutorial_visibility = settings_to_update["tutorialVisibility"] + if not isinstance(tutorial_visibility, dict): + return jsonify({"error": "Invalid tutorial visibility"}), 400 + for tutorial_id, visible in tutorial_visibility.items(): + if tutorial_id not in TUTORIAL_IDS or not isinstance(visible, bool): + return jsonify({"error": "Invalid tutorial visibility"}), 400 + settings_to_update["tutorialVisibility"] = dict(tutorial_visibility) + if "desktopNotificationsEnabled" in settings_to_update: if not isinstance(settings_to_update["desktopNotificationsEnabled"], bool): return jsonify({"error": "Invalid desktop notification preference"}), 400 diff --git a/application/single_app/route_backend_v2.py b/application/single_app/route_backend_v2.py index b6468ae01..0963e1418 100644 --- a/application/single_app/route_backend_v2.py +++ b/application/single_app/route_backend_v2.py @@ -200,7 +200,8 @@ ) from functions_documents import get_audio_runtime_capabilities from functions_cosmos_throughput import get_cosmos_resource_config -from config import VERSION +from config import IS_DEVELOPMENT, VERSION +from support_menu_config import has_visible_support_latest_features from swagger_wrapper import are_swagger_routes_registered, get_auth_security, swagger_route logger = logging.getLogger(__name__) @@ -400,7 +401,7 @@ def _build_navigation(raw_settings, user_roles): # Re-checked on the way out, not just on the way in. The V2 settings PATCH # is the only write path that applies the scheme rule, so a link stored # through the server-rendered admin form, or already in the document, could - # otherwise put a javascript: URL into every user's navigation. + # otherwise put a script-scheme URL into every user's navigation. if not is_safe_external_link_url(url): log_event( "[V2_BOOTSTRAP] Dropped an external link with an unsupported URL " @@ -427,6 +428,41 @@ def _build_navigation(raw_settings, user_roles): "force_menu": bool(raw_settings.get("external_links_force_menu")), "items": external_links, }, + "latest_features": _build_latest_features_nav(raw_settings, user_roles), + } + + +def _build_latest_features_nav(raw_settings, user_roles): + """Describe the Support menu's Latest Features shortcut for the SPA rail. + + Mirrors the gate in ``_sidebar_nav.html``: a real application role, the Support menu + and its Latest Features page switched on, and at least one feature left visible. + Whether this user hid the shortcut is not decided here; the browser compares the + user's ``latestFeaturesHiddenVersion`` with the bootstrap ``version`` so hiding and + unhiding take effect without a reload. + """ + may_see_support = any(role in ("Admin", "User") for role in user_roles or []) + available = False + if ( + may_see_support + and raw_settings.get("enable_support_menu") + and raw_settings.get("enable_support_latest_features", True) + ): + try: + available = bool(has_visible_support_latest_features(raw_settings)) + except Exception as exc: + log_event( + f"[V2_BOOTSTRAP] Could not resolve latest features visibility: {exc}", + level=logging.WARNING, + exceptionTraceback=True, + ) + available = False + + return { + "available": available, + "hidden_by_development": bool(IS_DEVELOPMENT), + "url": "/support/latest-features", + "menu_name": _menu_name(raw_settings.get("support_menu_name"), "Support"), } diff --git a/application/v2_ui/src/components/chat/Composer.tsx b/application/v2_ui/src/components/chat/Composer.tsx index f60bc420b..afebdae02 100644 --- a/application/v2_ui/src/components/chat/Composer.tsx +++ b/application/v2_ui/src/components/chat/Composer.tsx @@ -1555,7 +1555,7 @@ export function Composer({ initialAgentSelection }: { initialAgentSelection?: st )} -
+
{savedAnalysis && (
@@ -1713,7 +1713,7 @@ export function Composer({ initialAgentSelection }: { initialAgentSelection?: st
)} -
+
{orchestrationAvailable && ( fileInputRef.current?.click()} + data-tour="composer-attach" disabled={ !canPost || !gating.showFileUpload || @@ -2000,6 +2001,7 @@ export function Composer({ initialAgentSelection }: { initialAgentSelection?: st type="button" onClick={handleStop} aria-label="Stop generating" + data-tour="composer-send" className="inline-flex h-9 w-9 items-center justify-center rounded-xl bg-danger-soft text-danger transition-colors hover:bg-danger hover:text-white" > @@ -2008,6 +2010,7 @@ export function Composer({ initialAgentSelection }: { initialAgentSelection?: st +
+
+ ); +} diff --git a/application/v2_ui/src/components/layout/Sidebar.tsx b/application/v2_ui/src/components/layout/Sidebar.tsx index 13980426f..dfb146fa6 100644 --- a/application/v2_ui/src/components/layout/Sidebar.tsx +++ b/application/v2_ui/src/components/layout/Sidebar.tsx @@ -45,6 +45,7 @@ import { classicChatHref } from '../../lib/conversationUrl'; import { DEFAULT_PUBLIC_WORKSPACE_LABELS, usePublicWorkspaceLabels } from '../../lib/publicWorkspaceLabels'; import { ConversationRail } from '../chat/ConversationRail'; import { NavExtras } from './NavExtras'; +import { LatestFeaturesLink } from './LatestFeaturesLink'; import { NotificationBell } from './NotificationBell'; import { UserAvatar } from './UserAvatar'; import { useWorkflowAlertCalloutShown, WorkflowAlertRowSlot } from '../notifications/WorkflowAlertNotice'; @@ -279,6 +280,7 @@ function UserMenu({ collapsed }: { collapsed: boolean }) { onClick={() => setOpen((isOpen) => !isOpen)} aria-expanded={open} aria-controls={panelId} + data-tour="user-menu" // Collapsed, the avatar is the only content and carries no text of its own // once a profile photo replaces the initials, so the button needs a name. aria-label={collapsed ? 'Account' : undefined} @@ -456,6 +458,7 @@ export function Sidebar({ mobile = false }: { mobile?: boolean }) { if (mobile) setMobileNavOpen(false); }} title="Start a new chat" + data-tour="new-chat" className={clsx( 'flex w-full items-center gap-2 rounded-xl bg-accent px-3 py-2.5', 'text-sm font-medium text-on-accent transition-colors hover:bg-accent-hover', @@ -512,6 +515,9 @@ export function Sidebar({ mobile = false }: { mobile?: boolean }) { nothing when neither is enabled, which is the default. */} + {/* The Support menu's Latest Features shortcut, until the user hides it. */} + + {/* The conversation list only belongs in the rail while the chat page is open, so other pages get the full rail height for their own navigation. */} {showConversations && } diff --git a/application/v2_ui/src/components/settings/FactMemoryBench.tsx b/application/v2_ui/src/components/settings/FactMemoryBench.tsx new file mode 100644 index 000000000..892e42d5a --- /dev/null +++ b/application/v2_ui/src/components/settings/FactMemoryBench.tsx @@ -0,0 +1,438 @@ +// FactMemoryBench.tsx +// The signed-in user's saved memories: instructions applied to every reply, and facts recalled +// when they are relevant. +// +// Mirrors the classic profile page's Fact Memory section and its manager dialog, and saves +// through the same routes (/api/profile/fact-memory). The classic page split adding and +// managing across a card and a modal; here they share one bench, a searchable list beside an +// editor, in the style of the workspace benches. +// +// Shown whether or not an administrator turned fact memory on, as the classic page is: saved +// memories stay manageable, they just aren't used while the capability is off. + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { clsx } from 'clsx'; +import { Brain, Loader2, Plus, Search, Trash2 } from 'lucide-react'; +import { api, ApiError } from '../../lib/apiClient'; +import { toast } from '../../stores/toastStore'; +import { ConfirmDialog } from '../ui/ConfirmDialog'; +import { SettingsCard } from './SettingsCard'; + +export type MemoryType = 'instruction' | 'fact'; + +export interface FactMemoryItem { + id: string; + value: string; + memory_type: MemoryType; + created_at?: string; + updated_at?: string; +} + +interface FactMemoryPayload { + enabled?: boolean; + facts?: FactMemoryItem[]; +} + +type TypeFilter = 'all' | MemoryType; + +export const MEMORY_TYPE_LABELS: Record = { + instruction: 'Instruction: always apply to future responses', + fact: 'Fact: recall only when relevant', +}; + +const SHORT_TYPE_LABELS: Record = { + instruction: 'Instruction', + fact: 'Fact', +}; + +const FIELD_CLASS = + 'w-full rounded-lg border border-edge bg-surface-solid px-2.5 py-2 text-sm text-text-1'; + +function normalizeType(value: unknown): MemoryType { + return value === 'instruction' ? 'instruction' : 'fact'; +} + +function errorText(error: unknown, fallback: string): string { + return error instanceof ApiError || error instanceof Error ? error.message : fallback; +} + +function formatDate(value?: string): string { + if (!value) return 'Unknown'; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? 'Unknown' : date.toLocaleString(); +} + +/** Newest change first, the order the classic manager listed them in. */ +function byUpdated(left: FactMemoryItem, right: FactMemoryItem): number { + return (right.updated_at || right.created_at || '').localeCompare(left.updated_at || left.created_at || ''); +} + +function TypeSelect({ + id, + value, + onChange, + disabled, +}: { + id: string; + value: MemoryType; + onChange: (next: MemoryType) => void; + disabled?: boolean; +}) { + return ( + + ); +} + +export function FactMemoryBench() { + const [enabled, setEnabled] = useState(null); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + + const [newValue, setNewValue] = useState(''); + const [newType, setNewType] = useState('fact'); + const [adding, setAdding] = useState(false); + + const [query, setQuery] = useState(''); + const [typeFilter, setTypeFilter] = useState('all'); + const [selectedId, setSelectedId] = useState(null); + const [draftValue, setDraftValue] = useState(''); + const [draftType, setDraftType] = useState('fact'); + const [saving, setSaving] = useState(false); + const [confirmDelete, setConfirmDelete] = useState(false); + const [deleting, setDeleting] = useState(false); + + const load = useCallback(async (signal?: AbortSignal) => { + setLoading(true); + try { + const payload = await api.get('/api/profile/fact-memory', signal); + setEnabled(payload.enabled === true); + setItems((payload.facts ?? []).map((item) => ({ ...item, memory_type: normalizeType(item.memory_type) }))); + setLoadError(null); + } catch (error) { + if (signal?.aborted) return; + setLoadError(errorText(error, 'Your memories could not be loaded.')); + } finally { + if (!signal?.aborted) setLoading(false); + } + }, []); + + useEffect(() => { + const controller = new AbortController(); + void load(controller.signal); + return () => controller.abort(); + }, [load]); + + const sorted = useMemo(() => [...items].sort(byUpdated), [items]); + const instructionCount = items.filter((item) => item.memory_type === 'instruction').length; + const factCount = items.length - instructionCount; + const lastUpdated = sorted[0]?.updated_at || sorted[0]?.created_at; + + const visible = useMemo(() => { + const needle = query.trim().toLowerCase(); + return sorted.filter((item) => ( + (typeFilter === 'all' || item.memory_type === typeFilter) + && (!needle || item.value.toLowerCase().includes(needle)) + )); + }, [sorted, query, typeFilter]); + + const selected = items.find((item) => item.id === selectedId) ?? null; + + useEffect(() => { + if (selected) { + setDraftValue(selected.value); + setDraftType(selected.memory_type); + } + // Only reset the draft when a different memory is opened, not on every list change. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedId]); + + const replaceItem = (next: FactMemoryItem) => { + const normalized = { ...next, memory_type: normalizeType(next.memory_type) }; + setItems((current) => { + const index = current.findIndex((item) => item.id === normalized.id); + if (index === -1) return [normalized, ...current]; + const copy = [...current]; + copy[index] = normalized; + return copy; + }); + }; + + const add = async () => { + const value = newValue.trim(); + if (!value) return; + setAdding(true); + try { + const response = await api.post<{ fact?: FactMemoryItem }>('/api/profile/fact-memory', { + value, + memory_type: newType, + }); + if (response.fact) { + replaceItem(response.fact); + setSelectedId(response.fact.id); + setDraftValue(response.fact.value); + setDraftType(normalizeType(response.fact.memory_type)); + } else { + await load(); + } + setNewValue(''); + toast.success('Memory saved.'); + } catch (error) { + toast.error(errorText(error, 'The memory could not be saved.')); + } finally { + setAdding(false); + } + }; + + const save = async () => { + if (!selected) return; + const value = draftValue.trim(); + if (!value) return; + setSaving(true); + try { + const response = await api.put<{ fact?: FactMemoryItem }>( + `/api/profile/fact-memory/${encodeURIComponent(selected.id)}`, + { value, memory_type: draftType }, + ); + if (response.fact) replaceItem(response.fact); + toast.success('Memory updated.'); + } catch (error) { + toast.error(errorText(error, 'The memory could not be updated.')); + } finally { + setSaving(false); + } + }; + + const remove = async () => { + if (!selected) return; + setDeleting(true); + try { + await api.delete(`/api/profile/fact-memory/${encodeURIComponent(selected.id)}`); + setItems((current) => current.filter((item) => item.id !== selected.id)); + setSelectedId(null); + setConfirmDelete(false); + toast.success('Memory deleted.'); + } catch (error) { + toast.error(errorText(error, 'The memory could not be deleted.')); + } finally { + setDeleting(false); + } + }; + + const dirty = selected !== null + && (draftValue.trim() !== selected.value || draftType !== selected.memory_type); + + const badge = enabled === null ? null : ( + + {enabled ? 'Enabled by admin' : 'Disabled by admin'} + + ); + + return ( + + {enabled === false && ( +

+ Fact memory is turned off for your organization. You can still review and tidy your saved + memories, but they are not used in replies until an administrator turns it back on. +

+ )} + + {loadError ? ( +

+ {loadError} +

+ ) : ( + <> +

+ {loading + ? 'Loading your memories…' + : `${items.length} saved · ${instructionCount} ${instructionCount === 1 ? 'instruction' : 'instructions'}, ${factCount} ${factCount === 1 ? 'fact' : 'facts'}${lastUpdated ? ` · last updated ${formatDate(lastUpdated)}` : ''}`} +

+ +
{ + event.preventDefault(); + void add(); + }} + > +