Compare commits

..

6 Commits

Author SHA1 Message Date
Dane Urban
745a7c3faf . 2026-03-19 16:33:28 -07:00
Dane Urban
a20b9ea9b9 . 2026-03-19 16:32:16 -07:00
Dane Urban
820597e25f . 2026-03-19 16:29:12 -07:00
Dane Urban
1d85059b37 . 2026-03-19 16:28:21 -07:00
Dane Urban
616d3ceed5 . 2026-03-19 16:27:55 -07:00
Dane Urban
c8bbd68707 Cache plaintext 2026-03-19 16:15:12 -07:00
11 changed files with 553 additions and 560 deletions

View File

@@ -30,6 +30,8 @@ from onyx.file_processing.extract_file_text import extract_file_text
from onyx.file_store.file_store import get_default_file_store
from onyx.file_store.models import ChatFileType
from onyx.file_store.models import FileDescriptor
from onyx.file_store.utils import plaintext_file_name_for_id
from onyx.file_store.utils import store_plaintext
from onyx.kg.models import KGException
from onyx.kg.setup.kg_default_entity_definitions import (
populate_missing_default_entity_types__commit,
@@ -289,6 +291,33 @@ def process_kg_commands(
raise KGException("KG setup done")
def _get_or_extract_plaintext(
file_id: str,
extract_fn: Callable[[], str],
) -> str:
"""Load cached plaintext for a file, or extract and store it.
Tries to read pre-stored plaintext from the file store. On a miss,
calls extract_fn to produce the text, then stores the result so
future calls skip the expensive extraction.
"""
file_store = get_default_file_store()
plaintext_key = plaintext_file_name_for_id(file_id)
# Try cached plaintext first.
try:
plaintext_io = file_store.read_file(plaintext_key, mode="b")
return plaintext_io.read().decode("utf-8")
except Exception:
logger.error(f"Error when reading file, id={file_id}")
# Cache miss — extract and store.
content_text = extract_fn()
if content_text:
store_plaintext(file_id, content_text)
return content_text
@log_function_time(print_only=True)
def load_chat_file(
file_descriptor: FileDescriptor, db_session: Session
@@ -303,12 +332,23 @@ def load_chat_file(
file_type = ChatFileType(file_descriptor["type"])
if file_type.is_text_file():
try:
content_text = extract_file_text(
file_id = file_descriptor["id"]
def _extract() -> str:
return extract_file_text(
file=file_io,
file_name=file_descriptor.get("name") or "",
break_on_unprocessable=False,
)
# Use the user_file_id as cache key when available (matches what
# the celery indexing worker stores), otherwise fall back to the
# file store id (covers code-interpreter-generated files, etc.).
user_file_id_str = file_descriptor.get("user_file_id")
cache_key = user_file_id_str or file_id
try:
content_text = _get_or_extract_plaintext(cache_key, _extract)
except Exception as e:
logger.warning(
f"Failed to retrieve content for file {file_descriptor['id']}: {str(e)}"

View File

@@ -23,45 +23,55 @@ from onyx.utils.timing import log_function_time
logger = setup_logger()
def user_file_id_to_plaintext_file_name(user_file_id: UUID) -> str:
"""Generate a consistent file name for storing plaintext content of a user file."""
return f"plaintext_{user_file_id}"
def plaintext_file_name_for_id(file_id: str) -> str:
"""Generate a consistent file name for storing plaintext content of a file."""
return f"plaintext_{file_id}"
def store_user_file_plaintext(user_file_id: UUID, plaintext_content: str) -> bool:
def store_plaintext(file_id: str, plaintext_content: str) -> bool:
"""
Store plaintext content for a user file in the file store.
Store plaintext content for a file in the file store.
Args:
user_file_id: The ID of the user file
file_id: The ID of the file (user_file or artifact_file)
plaintext_content: The plaintext content to store
Returns:
bool: True if storage was successful, False otherwise
"""
# Skip empty content
if not plaintext_content:
return False
# Get plaintext file name
plaintext_file_name = user_file_id_to_plaintext_file_name(user_file_id)
plaintext_file_name = plaintext_file_name_for_id(file_id)
try:
file_store = get_default_file_store()
file_content = BytesIO(plaintext_content.encode("utf-8"))
file_store.save_file(
content=file_content,
display_name=f"Plaintext for user file {user_file_id}",
display_name=f"Plaintext for {file_id}",
file_origin=FileOrigin.PLAINTEXT_CACHE,
file_type="text/plain",
file_id=plaintext_file_name,
)
return True
except Exception as e:
logger.warning(f"Failed to store plaintext for user file {user_file_id}: {e}")
logger.warning(f"Failed to store plaintext for {file_id}: {e}")
return False
# --- Convenience wrappers for callers that use user-file UUIDs ---
def user_file_id_to_plaintext_file_name(user_file_id: UUID) -> str:
"""Generate a consistent file name for storing plaintext content of a user file."""
return plaintext_file_name_for_id(str(user_file_id))
def store_user_file_plaintext(user_file_id: UUID, plaintext_content: str) -> bool:
"""Store plaintext content for a user file (delegates to :func:`store_plaintext`)."""
return store_plaintext(str(user_file_id), plaintext_content)
def load_chat_file_by_id(file_id: str) -> InMemoryChatFile:
"""Load a file directly from the file store using its file_record ID.

View File

@@ -1,4 +1,3 @@
import hashlib
import mimetypes
from io import BytesIO
from typing import Any
@@ -84,14 +83,6 @@ class PythonTool(Tool[PythonToolOverrideKwargs]):
def __init__(self, tool_id: int, emitter: Emitter) -> None:
super().__init__(emitter=emitter)
self._id = tool_id
# Cache of (filename, content_hash) -> ci_file_id to avoid re-uploading
# the same file on every tool call iteration within the same agent session.
# Filename is included in the key so two files with identical bytes but
# different names each get their own upload slot.
# TTL assumption: code-interpreter file TTLs (typically hours) greatly
# exceed the lifetime of a single agent session (at most MAX_LLM_CYCLES
# iterations, typically a few minutes), so stale-ID eviction is not needed.
self._uploaded_file_cache: dict[tuple[str, str], str] = {}
@property
def id(self) -> int:
@@ -191,13 +182,8 @@ class PythonTool(Tool[PythonToolOverrideKwargs]):
for ind, chat_file in enumerate(chat_files):
file_name = chat_file.filename or f"file_{ind}"
try:
content_hash = hashlib.sha256(chat_file.content).hexdigest()
cache_key = (file_name, content_hash)
ci_file_id = self._uploaded_file_cache.get(cache_key)
if ci_file_id is None:
# Upload to Code Interpreter
ci_file_id = client.upload_file(chat_file.content, file_name)
self._uploaded_file_cache[cache_key] = ci_file_id
# Upload to Code Interpreter
ci_file_id = client.upload_file(chat_file.content, file_name)
# Stage for execution
files_to_stage.append({"path": file_name, "file_id": ci_file_id})
@@ -313,10 +299,14 @@ class PythonTool(Tool[PythonToolOverrideKwargs]):
f"Failed to delete Code Interpreter generated file {ci_file_id}: {e}"
)
# Note: staged input files are intentionally not deleted here because
# _uploaded_file_cache reuses their file_ids across iterations. They are
# orphaned when the session ends, but the code interpreter cleans up
# stale files on its own TTL.
# Cleanup staged input files
for file_mapping in files_to_stage:
try:
client.delete_file(file_mapping["file_id"])
except Exception as e:
logger.error(
f"Failed to delete Code Interpreter staged file {file_mapping['file_id']}: {e}"
)
# Emit file_ids once files are processed
if generated_file_ids:

View File

@@ -1219,16 +1219,15 @@ def test_code_interpreter_receives_chat_files(
finally:
ci_mod.CodeInterpreterClient.__init__.__defaults__ = original_defaults
# Verify: file uploaded and code executed via streaming.
# Verify: file uploaded, code executed via streaming, staged file cleaned up
assert len(mock_ci_server.get_requests(method="POST", path="/v1/files")) == 1
assert (
len(mock_ci_server.get_requests(method="POST", path="/v1/execute/stream")) == 1
)
# Staged input files are intentionally NOT deleted — PythonTool caches their
# file IDs across agent-loop iterations to avoid re-uploading on every call.
# The code interpreter cleans them up via its own TTL.
assert len(mock_ci_server.get_requests(method="DELETE")) == 0
delete_requests = mock_ci_server.get_requests(method="DELETE")
assert len(delete_requests) == 1
assert delete_requests[0].path.startswith("/v1/files/")
execute_body = mock_ci_server.get_requests(
method="POST", path="/v1/execute/stream"

View File

@@ -1,208 +0,0 @@
"""Unit tests for PythonTool file-upload caching.
Verifies that PythonTool reuses code-interpreter file IDs across multiple
run() calls within the same session instead of re-uploading identical content
on every agent loop iteration.
"""
from unittest.mock import MagicMock
from unittest.mock import patch
from onyx.tools.models import ChatFile
from onyx.tools.models import PythonToolOverrideKwargs
from onyx.tools.tool_implementations.python.code_interpreter_client import (
StreamResultEvent,
)
from onyx.tools.tool_implementations.python.python_tool import PythonTool
TOOL_MODULE = "onyx.tools.tool_implementations.python.python_tool"
def _make_stream_result() -> StreamResultEvent:
return StreamResultEvent(
exit_code=0,
timed_out=False,
duration_ms=10,
files=[],
)
def _make_tool() -> PythonTool:
emitter = MagicMock()
return PythonTool(tool_id=1, emitter=emitter)
def _make_override(files: list[ChatFile]) -> PythonToolOverrideKwargs:
return PythonToolOverrideKwargs(chat_files=files)
def _run_tool(tool: PythonTool, mock_client: MagicMock, files: list[ChatFile]) -> None:
"""Call tool.run() with a mocked CodeInterpreterClient context manager."""
from onyx.server.query_and_chat.placement import Placement
mock_client.execute_streaming.return_value = iter([_make_stream_result()])
ctx = MagicMock()
ctx.__enter__ = MagicMock(return_value=mock_client)
ctx.__exit__ = MagicMock(return_value=False)
placement = Placement(turn_index=0, tab_index=0)
override = _make_override(files)
with patch(f"{TOOL_MODULE}.CodeInterpreterClient", return_value=ctx):
tool.run(placement=placement, override_kwargs=override, code="print('hi')")
# ---------------------------------------------------------------------------
# Cache hit: same content uploaded in a second call reuses the file_id
# ---------------------------------------------------------------------------
@patch(f"{TOOL_MODULE}.CODE_INTERPRETER_BASE_URL", "http://fake:8000")
def test_same_file_uploaded_only_once_across_two_runs() -> None:
tool = _make_tool()
client = MagicMock()
client.upload_file.return_value = "file-id-abc"
pptx_content = b"fake pptx bytes"
files = [ChatFile(filename="report.pptx", content=pptx_content)]
_run_tool(tool, client, files)
_run_tool(tool, client, files)
# upload_file should only have been called once across both runs
client.upload_file.assert_called_once_with(pptx_content, "report.pptx")
@patch(f"{TOOL_MODULE}.CODE_INTERPRETER_BASE_URL", "http://fake:8000")
def test_cached_file_id_is_staged_on_second_run() -> None:
tool = _make_tool()
client = MagicMock()
client.upload_file.return_value = "file-id-abc"
files = [ChatFile(filename="data.pptx", content=b"content")]
_run_tool(tool, client, files)
# On the second run, execute_streaming should still receive the file
client.execute_streaming.return_value = iter([_make_stream_result()])
ctx = MagicMock()
ctx.__enter__ = MagicMock(return_value=client)
ctx.__exit__ = MagicMock(return_value=False)
from onyx.server.query_and_chat.placement import Placement
placement = Placement(turn_index=1, tab_index=0)
with patch(f"{TOOL_MODULE}.CodeInterpreterClient", return_value=ctx):
tool.run(
placement=placement,
override_kwargs=_make_override(files),
code="print('hi')",
)
# The second execute_streaming call should include the file
_, kwargs = client.execute_streaming.call_args
staged_files = kwargs.get("files") or []
assert any(f["file_id"] == "file-id-abc" for f in staged_files)
# ---------------------------------------------------------------------------
# Cache miss: different content triggers a new upload
# ---------------------------------------------------------------------------
@patch(f"{TOOL_MODULE}.CODE_INTERPRETER_BASE_URL", "http://fake:8000")
def test_different_file_content_uploaded_separately() -> None:
tool = _make_tool()
client = MagicMock()
client.upload_file.side_effect = ["file-id-v1", "file-id-v2"]
file_v1 = ChatFile(filename="report.pptx", content=b"version 1")
file_v2 = ChatFile(filename="report.pptx", content=b"version 2")
_run_tool(tool, client, [file_v1])
_run_tool(tool, client, [file_v2])
assert client.upload_file.call_count == 2
@patch(f"{TOOL_MODULE}.CODE_INTERPRETER_BASE_URL", "http://fake:8000")
def test_multiple_distinct_files_each_uploaded_once() -> None:
tool = _make_tool()
client = MagicMock()
client.upload_file.side_effect = ["id-a", "id-b"]
files = [
ChatFile(filename="a.pptx", content=b"aaa"),
ChatFile(filename="b.xlsx", content=b"bbb"),
]
_run_tool(tool, client, files)
_run_tool(tool, client, files)
# Two distinct files — each uploaded exactly once
assert client.upload_file.call_count == 2
@patch(f"{TOOL_MODULE}.CODE_INTERPRETER_BASE_URL", "http://fake:8000")
def test_same_content_different_filename_uploaded_separately() -> None:
# Identical bytes but different names must each get their own upload slot
# so both files appear under their respective paths in the workspace.
tool = _make_tool()
client = MagicMock()
client.upload_file.side_effect = ["id-v1", "id-v2"]
same_bytes = b"shared content"
files = [
ChatFile(filename="report_v1.csv", content=same_bytes),
ChatFile(filename="report_v2.csv", content=same_bytes),
]
_run_tool(tool, client, files)
assert client.upload_file.call_count == 2
# ---------------------------------------------------------------------------
# No cross-instance sharing: a fresh PythonTool re-uploads everything
# ---------------------------------------------------------------------------
@patch(f"{TOOL_MODULE}.CODE_INTERPRETER_BASE_URL", "http://fake:8000")
def test_new_tool_instance_re_uploads_file() -> None:
client = MagicMock()
client.upload_file.side_effect = ["id-session-1", "id-session-2"]
files = [ChatFile(filename="deck.pptx", content=b"slide data")]
tool_session_1 = _make_tool()
_run_tool(tool_session_1, client, files)
tool_session_2 = _make_tool()
_run_tool(tool_session_2, client, files)
# Different instances — each uploads independently
assert client.upload_file.call_count == 2
# ---------------------------------------------------------------------------
# Upload failure: failed upload is not cached, retried next run
# ---------------------------------------------------------------------------
@patch(f"{TOOL_MODULE}.CODE_INTERPRETER_BASE_URL", "http://fake:8000")
def test_upload_failure_not_cached() -> None:
tool = _make_tool()
client = MagicMock()
# First call raises, second succeeds
client.upload_file.side_effect = [Exception("network error"), "file-id-ok"]
files = [ChatFile(filename="slides.pptx", content=b"data")]
# First run — upload fails, file is skipped but not cached
_run_tool(tool, client, files)
# Second run — should attempt upload again
_run_tool(tool, client, files)
assert client.upload_file.call_count == 2

View File

@@ -1,162 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Hoverable } from "@opal/core";
// ---------------------------------------------------------------------------
// Meta
// ---------------------------------------------------------------------------
const meta: Meta = {
title: "Core/Hoverable",
tags: ["autodocs"],
parameters: {
layout: "centered",
},
};
export default meta;
// ---------------------------------------------------------------------------
// Stories
// ---------------------------------------------------------------------------
/** Group mode — hovering the root reveals hidden items. */
export const GroupMode: StoryObj = {
render: () => (
<Hoverable.Root group="demo">
<div
style={{
display: "flex",
alignItems: "center",
gap: "0.75rem",
padding: "1rem",
border: "1px solid var(--border-02)",
borderRadius: "0.5rem",
minWidth: 260,
}}
>
<span style={{ color: "var(--text-01)" }}>Hover this card</span>
<Hoverable.Item group="demo" variant="opacity-on-hover">
<span style={{ color: "var(--text-03)" }}> Revealed</span>
</Hoverable.Item>
</div>
</Hoverable.Root>
),
};
/** Local mode — hovering the item itself reveals it (no Root needed). */
export const LocalMode: StoryObj = {
render: () => (
<div
style={{
display: "flex",
alignItems: "center",
gap: "0.75rem",
padding: "1rem",
}}
>
<span style={{ color: "var(--text-01)" }}>Hover the icon </span>
<Hoverable.Item variant="opacity-on-hover">
<span style={{ fontSize: "1.25rem" }}>🗑</span>
</Hoverable.Item>
</div>
),
};
/** Multiple independent groups on the same page. */
export const MultipleGroups: StoryObj = {
render: () => (
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
{(["alpha", "beta"] as const).map((group) => (
<Hoverable.Root key={group} group={group}>
<div
style={{
display: "flex",
alignItems: "center",
gap: "0.75rem",
padding: "1rem",
border: "1px solid var(--border-02)",
borderRadius: "0.5rem",
}}
>
<span style={{ color: "var(--text-01)" }}>Group: {group}</span>
<Hoverable.Item group={group} variant="opacity-on-hover">
<span style={{ color: "var(--text-03)" }}> Revealed</span>
</Hoverable.Item>
</div>
</Hoverable.Root>
))}
</div>
),
};
/** Multiple items revealed by a single root. */
export const MultipleItems: StoryObj = {
render: () => (
<Hoverable.Root group="multi">
<div
style={{
display: "flex",
alignItems: "center",
gap: "0.75rem",
padding: "1rem",
border: "1px solid var(--border-02)",
borderRadius: "0.5rem",
}}
>
<span style={{ color: "var(--text-01)" }}>Hover to reveal all</span>
<Hoverable.Item group="multi" variant="opacity-on-hover">
<span>Edit</span>
</Hoverable.Item>
<Hoverable.Item group="multi" variant="opacity-on-hover">
<span>Delete</span>
</Hoverable.Item>
<Hoverable.Item group="multi" variant="opacity-on-hover">
<span>Share</span>
</Hoverable.Item>
</div>
</Hoverable.Root>
),
};
/** Nested groups — inner and outer hover independently. */
export const NestedGroups: StoryObj = {
render: () => (
<Hoverable.Root group="outer">
<div
style={{
padding: "1rem",
border: "1px solid var(--border-02)",
borderRadius: "0.5rem",
display: "flex",
flexDirection: "column",
gap: "0.75rem",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
<span style={{ color: "var(--text-01)" }}>Outer card</span>
<Hoverable.Item group="outer" variant="opacity-on-hover">
<span style={{ color: "var(--text-03)" }}>Outer action</span>
</Hoverable.Item>
</div>
<Hoverable.Root group="inner">
<div
style={{
display: "flex",
alignItems: "center",
gap: "0.75rem",
padding: "0.75rem",
border: "1px solid var(--border-03)",
borderRadius: "0.375rem",
}}
>
<span style={{ color: "var(--text-02)" }}>Inner card</span>
<Hoverable.Item group="inner" variant="opacity-on-hover">
<span style={{ color: "var(--text-03)" }}>Inner action</span>
</Hoverable.Item>
</div>
</Hoverable.Root>
</div>
</Hoverable.Root>
),
};

View File

@@ -0,0 +1,127 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Hoverable } from "@opal/core";
import SvgX from "@opal/icons/x";
// ---------------------------------------------------------------------------
// Shared styles
// ---------------------------------------------------------------------------
const cardStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "1rem",
padding: "0.75rem 1rem",
borderRadius: "0.5rem",
border: "1px solid var(--border-02)",
background: "var(--background-neutral-01)",
minWidth: 220,
};
const labelStyle: React.CSSProperties = {
fontSize: "0.875rem",
fontWeight: 500,
};
// ---------------------------------------------------------------------------
// Meta
// ---------------------------------------------------------------------------
const meta: Meta = {
title: "Core/Hoverable",
tags: ["autodocs"],
parameters: {
layout: "centered",
},
};
export default meta;
// ---------------------------------------------------------------------------
// Stories
// ---------------------------------------------------------------------------
/**
* Local hover mode -- no `group` prop on the Item.
* The icon only appears when you hover directly over the Item element itself.
*/
export const LocalHover: StoryObj = {
render: () => (
<div style={cardStyle}>
<span style={labelStyle}>Hover this card area</span>
<Hoverable.Item variant="opacity-on-hover">
<SvgX width={16} height={16} />
</Hoverable.Item>
</div>
),
};
/**
* Group hover mode -- hovering anywhere inside the Root reveals the Item.
*/
export const GroupHover: StoryObj = {
render: () => (
<Hoverable.Root group="card">
<div style={cardStyle}>
<span style={labelStyle}>Hover anywhere on this card</span>
<Hoverable.Item group="card" variant="opacity-on-hover">
<SvgX width={16} height={16} />
</Hoverable.Item>
</div>
</Hoverable.Root>
),
};
/**
* Nested groups demonstrating isolation.
*
* - Hovering the outer card reveals only the outer icon.
* - Hovering the inner card reveals only the inner icon.
*/
export const NestedGroups: StoryObj = {
render: () => (
<Hoverable.Root group="outer">
<div
style={{
...cardStyle,
flexDirection: "column",
alignItems: "stretch",
gap: "0.75rem",
padding: "1rem",
minWidth: 300,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<span style={labelStyle}>Outer card</span>
<Hoverable.Item group="outer" variant="opacity-on-hover">
<SvgX width={16} height={16} />
</Hoverable.Item>
</div>
<Hoverable.Root group="inner">
<div
style={{
...cardStyle,
background: "var(--background-neutral-02)",
}}
>
<span style={labelStyle}>Inner card</span>
<Hoverable.Item group="inner" variant="opacity-on-hover">
<SvgX width={16} height={16} />
</Hoverable.Item>
</div>
</Hoverable.Root>
</div>
</Hoverable.Root>
),
};

View File

@@ -1,16 +1,16 @@
"use client";
import Image from "next/image";
import { useMemo, useState } from "react";
import { AdminPageTitle } from "@/components/admin/Title";
import {
AzureIcon,
ElevenLabsIcon,
IconProps,
InfoIcon,
OpenAIIcon,
} from "@/components/icons/icons";
import Text from "@/refresh-components/texts/Text";
import { Select } from "@/refresh-components/cards";
import Message from "@/refresh-components/messages/Message";
import * as SettingsLayouts from "@/layouts/settings-layouts";
import Separator from "@/refresh-components/Separator";
import { FetchError } from "@/lib/fetcher";
import {
useVoiceProviders,
@@ -22,21 +22,32 @@ import {
} from "@/lib/admin/voice/svc";
import { ThreeDotsLoader } from "@/components/Loading";
import { Callout } from "@/components/ui/callout";
import { Content } from "@opal/layouts";
import { SvgMicrophone } from "@opal/icons";
import { ADMIN_ROUTES } from "@/lib/admin-routes";
import Button from "@/refresh-components/buttons/Button";
import { Button as OpalButton } from "@opal/components";
import { cn } from "@/lib/utils";
import {
SvgArrowExchange,
SvgArrowRightCircle,
SvgAudio,
SvgCheckSquare,
SvgEdit,
SvgMicrophone,
SvgX,
} from "@opal/icons";
import VoiceProviderSetupModal from "./VoiceProviderSetupModal";
interface ModelDetails {
id: string;
label: string;
subtitle: string;
logoSrc?: string;
providerType: string;
}
interface ProviderGroup {
providerType: string;
providerLabel: string;
logoSrc?: string;
models: ModelDetails[];
}
@@ -46,18 +57,21 @@ const STT_MODELS: ModelDetails[] = [
id: "whisper",
label: "Whisper",
subtitle: "OpenAI's general purpose speech recognition model.",
logoSrc: "/Openai.svg",
providerType: "openai",
},
{
id: "azure-speech-stt",
label: "Azure Speech",
subtitle: "Speech to text in Microsoft Foundry Tools.",
logoSrc: "/Azure.png",
providerType: "azure",
},
{
id: "elevenlabs-stt",
label: "ElevenAPI",
subtitle: "ElevenLabs Speech to Text API.",
logoSrc: "/ElevenLabs.svg",
providerType: "elevenlabs",
},
];
@@ -67,17 +81,20 @@ const TTS_PROVIDER_GROUPS: ProviderGroup[] = [
{
providerType: "openai",
providerLabel: "OpenAI",
logoSrc: "/Openai.svg",
models: [
{
id: "tts-1",
label: "TTS-1",
subtitle: "OpenAI's text-to-speech model optimized for speed.",
logoSrc: "/Openai.svg",
providerType: "openai",
},
{
id: "tts-1-hd",
label: "TTS-1 HD",
subtitle: "OpenAI's text-to-speech model optimized for quality.",
logoSrc: "/Openai.svg",
providerType: "openai",
},
],
@@ -85,11 +102,13 @@ const TTS_PROVIDER_GROUPS: ProviderGroup[] = [
{
providerType: "azure",
providerLabel: "Azure",
logoSrc: "/Azure.png",
models: [
{
id: "azure-speech-tts",
label: "Azure Speech",
subtitle: "Text to speech in Microsoft Foundry Tools.",
logoSrc: "/Azure.png",
providerType: "azure",
},
],
@@ -97,42 +116,44 @@ const TTS_PROVIDER_GROUPS: ProviderGroup[] = [
{
providerType: "elevenlabs",
providerLabel: "ElevenLabs",
logoSrc: "/ElevenLabs.svg",
models: [
{
id: "elevenlabs-tts",
label: "ElevenAPI",
subtitle: "ElevenLabs Text to Speech API.",
logoSrc: "/ElevenLabs.svg",
providerType: "elevenlabs",
},
],
},
];
const FallbackMicrophoneIcon = ({ size, className }: IconProps) => (
<SvgMicrophone size={size} className={className} />
);
interface HoverIconButtonProps extends React.ComponentProps<typeof Button> {
isHovered: boolean;
onMouseEnter: () => void;
onMouseLeave: () => void;
children: React.ReactNode;
}
function getProviderIcon(
providerType: string
): React.FunctionComponent<IconProps> {
switch (providerType) {
case "openai":
return OpenAIIcon;
case "azure":
return AzureIcon;
case "elevenlabs":
return ElevenLabsIcon;
default:
return FallbackMicrophoneIcon;
}
function HoverIconButton({
isHovered,
onMouseEnter,
onMouseLeave,
children,
...buttonProps
}: HoverIconButtonProps) {
return (
<div onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
<Button {...buttonProps} rightIcon={isHovered ? SvgX : SvgCheckSquare}>
{children}
</Button>
</div>
);
}
type ProviderMode = "stt" | "tts";
const route = ADMIN_ROUTES.VOICE;
const pageDescription =
"Configure speech-to-text and text-to-speech providers for voice input and spoken responses.";
export default function VoiceConfigurationPage() {
const [modalOpen, setModalOpen] = useState(false);
const [selectedProvider, setSelectedProvider] = useState<string | null>(null);
@@ -146,6 +167,7 @@ export default function VoiceConfigurationPage() {
const [ttsActivationError, setTTSActivationError] = useState<string | null>(
null
);
const [hoveredButtonKey, setHoveredButtonKey] = useState<string | null>(null);
const { providers, error, isLoading, refresh: mutate } = useVoiceProviders();
@@ -239,6 +261,7 @@ export default function VoiceConfigurationPage() {
return !!provider?.has_api_key;
};
// Map provider types to their configured provider data
const providersByType = useMemo(() => {
return new Map((providers ?? []).map((p) => [p.provider_type, p] as const));
}, [providers]);
@@ -248,46 +271,186 @@ export default function VoiceConfigurationPage() {
const hasActiveTTSProvider =
providers?.some((p) => p.is_default_tts) ?? false;
const getModelStatus = (
model: ModelDetails,
mode: ProviderMode
): "disconnected" | "connected" | "selected" => {
const provider = providersByType.get(model.providerType);
if (!provider || !isProviderConfigured(provider)) return "disconnected";
const isActive =
mode === "stt"
? provider.is_default_stt
: provider.is_default_tts && provider.tts_model === model.id;
if (isActive) return "selected";
return "connected";
};
const renderModelSelect = (model: ModelDetails, mode: ProviderMode) => {
const provider = providersByType.get(model.providerType);
const status = getModelStatus(model, mode);
const Icon = getProviderIcon(model.providerType);
const renderLogo = ({
logoSrc,
providerType,
alt,
size = 16,
}: {
logoSrc?: string;
providerType: string;
alt: string;
size?: number;
}) => {
const containerSizeClass = size === 24 ? "size-7" : "size-5";
return (
<Select
<div
className={cn(
"flex items-center justify-center px-0.5 py-0 shrink-0 overflow-clip",
containerSizeClass
)}
>
{providerType === "openai" ? (
<OpenAIIcon size={size} />
) : providerType === "azure" ? (
<AzureIcon size={size} />
) : providerType === "elevenlabs" ? (
<ElevenLabsIcon size={size} />
) : logoSrc ? (
<Image
src={logoSrc}
alt={alt}
width={size}
height={size}
className="object-contain"
/>
) : (
<SvgMicrophone size={size} className="text-text-02" />
)}
</div>
);
};
const renderModelCard = ({
model,
mode,
}: {
model: ModelDetails;
mode: ProviderMode;
}) => {
const provider = providersByType.get(model.providerType);
const isConfigured = isProviderConfigured(provider);
// For TTS, also check that this specific model is the default (not just the provider)
const isActive =
mode === "stt"
? provider?.is_default_stt
: provider?.is_default_tts && provider?.tts_model === model.id;
const isHighlighted = isActive ?? false;
const providerId = provider?.id;
const buttonState = (() => {
if (!provider || !isConfigured) {
return {
label: "Connect",
disabled: false,
icon: "arrow" as const,
onClick: () => handleConnect(model.providerType, mode, model.id),
};
}
if (isActive) {
return {
label: "Current Default",
disabled: false,
icon: "check" as const,
onClick: providerId
? () => handleDeactivate(providerId, mode)
: undefined,
};
}
return {
label: "Set as Default",
disabled: false,
icon: "arrow-circle" as const,
onClick: providerId
? () => handleSetDefault(providerId, mode, model.id)
: undefined,
};
})();
const buttonKey = `${mode}-${model.id}`;
const isButtonHovered = hoveredButtonKey === buttonKey;
const isCardClickable =
buttonState.icon === "arrow" &&
typeof buttonState.onClick === "function" &&
!buttonState.disabled;
const handleCardClick = () => {
if (isCardClickable) {
buttonState.onClick?.();
}
};
return (
<div
key={`${mode}-${model.id}`}
aria-label={`voice-${mode}-${model.id}`}
icon={Icon}
title={model.label}
description={model.subtitle}
status={status}
onConnect={() => handleConnect(model.providerType, mode, model.id)}
onSelect={() => {
if (provider?.id) handleSetDefault(provider.id, mode, model.id);
}}
onDeselect={() => {
if (provider?.id) handleDeactivate(provider.id, mode);
}}
onEdit={() => {
if (provider) handleEdit(provider, mode, model.id);
}}
/>
onClick={isCardClickable ? handleCardClick : undefined}
className={cn(
"flex items-start justify-between gap-4 rounded-16 border p-2 bg-background-neutral-01",
isHighlighted ? "border-action-link-05" : "border-border-01",
isCardClickable &&
"cursor-pointer hover:bg-background-tint-01 transition-colors"
)}
>
<div className="flex flex-1 items-start gap-2.5 p-2">
{renderLogo({
logoSrc: model.logoSrc,
providerType: model.providerType,
alt: `${model.label} logo`,
size: 16,
})}
<div className="flex flex-col gap-0.5">
<Text as="p" mainUiAction text04>
{model.label}
</Text>
<Text as="p" secondaryBody text03>
{model.subtitle}
</Text>
</div>
</div>
<div className="flex items-center justify-end gap-1.5 self-center">
{isConfigured && (
<OpalButton
icon={SvgEdit}
tooltip="Edit"
prominence="tertiary"
size="sm"
onClick={(e) => {
e.stopPropagation();
if (provider) handleEdit(provider, mode, model.id);
}}
aria-label={`Edit ${model.label}`}
/>
)}
{buttonState.icon === "check" ? (
<HoverIconButton
isHovered={isButtonHovered}
onMouseEnter={() => setHoveredButtonKey(buttonKey)}
onMouseLeave={() => setHoveredButtonKey(null)}
action={true}
tertiary
disabled={buttonState.disabled}
onClick={(e) => {
e.stopPropagation();
buttonState.onClick?.();
}}
>
{buttonState.label}
</HoverIconButton>
) : (
<Button
action={false}
tertiary
disabled={buttonState.disabled || !buttonState.onClick}
onClick={(e) => {
e.stopPropagation();
buttonState.onClick?.();
}}
rightIcon={
buttonState.icon === "arrow"
? SvgArrowExchange
: buttonState.icon === "arrow-circle"
? SvgArrowRightCircle
: undefined
}
>
{buttonState.label}
</Button>
)}
</div>
</div>
);
};
@@ -299,56 +462,61 @@ export default function VoiceConfigurationPage() {
: undefined;
return (
<SettingsLayouts.Root>
<SettingsLayouts.Header
icon={route.icon}
title={route.title}
description={pageDescription}
<>
<AdminPageTitle
title="Voice"
icon={SvgMicrophone}
includeDivider={false}
/>
<SettingsLayouts.Body>
<Callout type="danger" title="Failed to load voice settings">
{message}
{detail && (
<Text as="p" mainContentBody text03>
{detail}
</Text>
)}
</Callout>
</SettingsLayouts.Body>
</SettingsLayouts.Root>
<Callout type="danger" title="Failed to load voice settings">
{message}
{detail && (
<Text as="p" className="mt-2 text-text-03" mainContentBody text03>
{detail}
</Text>
)}
</Callout>
</>
);
}
if (isLoading) {
return (
<SettingsLayouts.Root>
<SettingsLayouts.Header
icon={route.icon}
title={route.title}
description={pageDescription}
<>
<AdminPageTitle
title="Voice"
icon={SvgMicrophone}
includeDivider={false}
/>
<SettingsLayouts.Body>
<div className="mt-8">
<ThreeDotsLoader />
</SettingsLayouts.Body>
</SettingsLayouts.Root>
</div>
</>
);
}
return (
<SettingsLayouts.Root>
<SettingsLayouts.Header
icon={route.icon}
title={route.title}
description={pageDescription}
/>
<SettingsLayouts.Body>
<div className="flex flex-col gap-6">
<Content
title="Speech to Text"
description="Select a model to transcribe speech to text in chats."
sizePreset="main-content"
variant="section"
/>
<>
<AdminPageTitle icon={SvgAudio} title="Voice" />
<div className="pt-4 pb-4">
<Text as="p" secondaryBody text03>
Speech to text (STT) and text to speech (TTS) capabilities.
</Text>
</div>
<Separator />
<div className="flex w-full flex-col gap-8 pb-6">
{/* Speech-to-Text Section */}
<div className="flex w-full max-w-[960px] flex-col gap-3">
<div className="flex flex-col">
<Text as="p" mainContentEmphasis text04>
Speech to Text
</Text>
<Text as="p" secondaryBody text03>
Select a model to transcribe speech to text in chats.
</Text>
</div>
{sttActivationError && (
<Callout type="danger" title="Unable to update STT provider">
@@ -357,28 +525,46 @@ export default function VoiceConfigurationPage() {
)}
{!hasActiveSTTProvider && (
<Message
info
static
large
close={false}
text="Connect a speech to text provider to use in chat."
className="w-full"
/>
<div
className="flex items-start rounded-16 border p-2"
style={{
backgroundColor: "var(--status-info-00)",
borderColor: "var(--status-info-02)",
}}
>
<div className="flex items-start gap-1 p-2">
<div
className="flex size-5 items-center justify-center rounded-full p-0.5"
style={{
backgroundColor: "var(--status-info-01)",
}}
>
<div style={{ color: "var(--status-text-info-05)" }}>
<InfoIcon size={16} />
</div>
</div>
<Text as="p" className="flex-1 px-0.5" mainUiBody text04>
Connect a speech to text provider to use in chat.
</Text>
</div>
</div>
)}
<div className="flex flex-col gap-2">
{STT_MODELS.map((model) => renderModelSelect(model, "stt"))}
{STT_MODELS.map((model) => renderModelCard({ model, mode: "stt" }))}
</div>
</div>
<div className="flex flex-col gap-6">
<Content
title="Text to Speech"
description="Select a model to speak out chat responses."
sizePreset="main-content"
variant="section"
/>
{/* Text-to-Speech Section */}
<div className="flex w-full max-w-[960px] flex-col gap-3">
<div className="flex flex-col">
<Text as="p" mainContentEmphasis text04>
Text to Speech
</Text>
<Text as="p" secondaryBody text03>
Select a model to speak out chat responses.
</Text>
</div>
{ttsActivationError && (
<Callout type="danger" title="Unable to update TTS provider">
@@ -387,28 +573,47 @@ export default function VoiceConfigurationPage() {
)}
{!hasActiveTTSProvider && (
<Message
info
static
large
close={false}
text="Connect a text to speech provider to use in chat."
className="w-full"
/>
)}
{TTS_PROVIDER_GROUPS.map((group) => (
<div key={group.providerType} className="flex flex-col gap-2">
<Text secondaryBody text03>
{group.providerLabel}
</Text>
<div className="flex flex-col gap-2">
{group.models.map((model) => renderModelSelect(model, "tts"))}
<div
className="flex items-start rounded-16 border p-2"
style={{
backgroundColor: "var(--status-info-00)",
borderColor: "var(--status-info-02)",
}}
>
<div className="flex items-start gap-1 p-2">
<div
className="flex size-5 items-center justify-center rounded-full p-0.5"
style={{
backgroundColor: "var(--status-info-01)",
}}
>
<div style={{ color: "var(--status-text-info-05)" }}>
<InfoIcon size={16} />
</div>
</div>
<Text as="p" className="flex-1 px-0.5" mainUiBody text04>
Connect a text to speech provider to use in chat.
</Text>
</div>
</div>
))}
)}
<div className="flex flex-col gap-4">
{TTS_PROVIDER_GROUPS.map((group) => (
<div key={group.providerType} className="flex flex-col gap-2">
<Text as="p" secondaryBody text03 className="px-0.5">
{group.providerLabel}
</Text>
<div className="flex flex-col gap-2">
{group.models.map((model) =>
renderModelCard({ model, mode: "tts" })
)}
</div>
</div>
))}
</div>
</div>
</SettingsLayouts.Body>
</div>
{modalOpen && selectedProvider && (
<VoiceProviderSetupModal
@@ -420,6 +625,6 @@ export default function VoiceConfigurationPage() {
onSuccess={handleModalSuccess}
/>
)}
</SettingsLayouts.Root>
</>
);
}

View File

@@ -45,7 +45,6 @@ const SETTINGS_LAYOUT_PREFIXES = [
ADMIN_ROUTES.GROUPS.path,
ADMIN_ROUTES.PERFORMANCE.path,
ADMIN_ROUTES.SCIM.path,
ADMIN_ROUTES.VOICE.path,
];
export function ClientLayout({ children, enableCloud }: ClientLayoutProps) {

View File

@@ -451,19 +451,13 @@ const ModalHeader = React.forwardRef<HTMLDivElement, ModalHeaderProps>(
);
return (
<Section
ref={ref}
padding={0.5}
alignItems="start"
height="fit"
{...props}
>
<Section ref={ref} padding={1} alignItems="start" height="fit" {...props}>
<Section
flexDirection="row"
justifyContent="between"
alignItems="start"
gap={0}
padding={0.5}
padding={0}
>
<div className="relative w-full">
{/* Close button is absolutely positioned because:
@@ -491,6 +485,7 @@ const ModalHeader = React.forwardRef<HTMLDivElement, ModalHeaderProps>(
</DialogPrimitive.Title>
</div>
</Section>
{children}
</Section>
);

View File

@@ -112,11 +112,9 @@ function MemoryItem({
/>
</Disabled>
</Section>
<div
className={isFocused ? "visible" : "invisible h-0 overflow-hidden"}
>
{isFocused && (
<CharacterCount value={memory.content} limit={MAX_MEMORY_LENGTH} />
</div>
)}
</Section>
</div>
);