mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-04-07 07:52:44 +00:00
Compare commits
4 Commits
nikg/fix-s
...
nikolas/mu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6dbf26ca04 | ||
|
|
809dab5746 | ||
|
|
1649bed548 | ||
|
|
dd07b3cf27 |
42
.github/workflows/deployment.yml
vendored
42
.github/workflows/deployment.yml
vendored
@@ -29,15 +29,27 @@ jobs:
|
||||
build-backend-craft: ${{ steps.check.outputs.build-backend-craft }}
|
||||
build-model-server: ${{ steps.check.outputs.build-model-server }}
|
||||
is-cloud-tag: ${{ steps.check.outputs.is-cloud-tag }}
|
||||
is-stable: ${{ steps.check.outputs.is-stable }}
|
||||
is-beta: ${{ steps.check.outputs.is-beta }}
|
||||
is-stable-standalone: ${{ steps.check.outputs.is-stable-standalone }}
|
||||
is-beta-standalone: ${{ steps.check.outputs.is-beta-standalone }}
|
||||
is-craft-latest: ${{ steps.check.outputs.is-craft-latest }}
|
||||
is-latest: ${{ steps.check.outputs.is-latest }}
|
||||
is-test-run: ${{ steps.check.outputs.is-test-run }}
|
||||
sanitized-tag: ${{ steps.check.outputs.sanitized-tag }}
|
||||
short-sha: ${{ steps.check.outputs.short-sha }}
|
||||
steps:
|
||||
- name: Checkout (for git tags)
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # ratchet:actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- name: Setup uv
|
||||
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # ratchet:astral-sh/setup-uv@v7
|
||||
with:
|
||||
version: "0.9.9"
|
||||
enable-cache: false
|
||||
|
||||
- name: Check which components to build and version info
|
||||
id: check
|
||||
env:
|
||||
@@ -54,9 +66,9 @@ jobs:
|
||||
IS_VERSION_TAG=false
|
||||
IS_STABLE=false
|
||||
IS_BETA=false
|
||||
IS_STABLE_STANDALONE=false
|
||||
IS_BETA_STANDALONE=false
|
||||
IS_CRAFT_LATEST=false
|
||||
IS_LATEST=false
|
||||
IS_PROD_TAG=false
|
||||
IS_TEST_RUN=false
|
||||
BUILD_DESKTOP=false
|
||||
@@ -104,13 +116,22 @@ jobs:
|
||||
fi
|
||||
|
||||
# Standalone version checks (for backend/model-server - version excluding cloud tags)
|
||||
if [[ "$IS_STABLE" == "true" ]] && [[ "$IS_CLOUD" != "true" ]]; then
|
||||
IS_STABLE_STANDALONE=true
|
||||
fi
|
||||
if [[ "$IS_BETA" == "true" ]] && [[ "$IS_CLOUD" != "true" ]]; then
|
||||
IS_BETA_STANDALONE=true
|
||||
fi
|
||||
|
||||
# Determine if this tag should get the "latest" Docker tag.
|
||||
# Only the highest semver stable tag (vX.Y.Z exactly) gets "latest".
|
||||
if [[ "$IS_STABLE" == "true" ]]; then
|
||||
HIGHEST_STABLE=$(uv run --no-sync --with onyx-devtools ods latest-stable-tag) || {
|
||||
echo "::error::Failed to determine highest stable tag via 'ods latest-stable-tag'"
|
||||
exit 1
|
||||
}
|
||||
if [[ "$TAG" == "$HIGHEST_STABLE" ]]; then
|
||||
IS_LATEST=true
|
||||
fi
|
||||
fi
|
||||
|
||||
# Determine if this is a production tag
|
||||
# Production tags are: version tags (v1.2.3*) or nightly tags
|
||||
if [[ "$IS_VERSION_TAG" == "true" ]] || [[ "$IS_NIGHTLY" == "true" ]]; then
|
||||
@@ -129,11 +150,10 @@ jobs:
|
||||
echo "build-backend-craft=$BUILD_BACKEND_CRAFT"
|
||||
echo "build-model-server=$BUILD_MODEL_SERVER"
|
||||
echo "is-cloud-tag=$IS_CLOUD"
|
||||
echo "is-stable=$IS_STABLE"
|
||||
echo "is-beta=$IS_BETA"
|
||||
echo "is-stable-standalone=$IS_STABLE_STANDALONE"
|
||||
echo "is-beta-standalone=$IS_BETA_STANDALONE"
|
||||
echo "is-craft-latest=$IS_CRAFT_LATEST"
|
||||
echo "is-latest=$IS_LATEST"
|
||||
echo "is-test-run=$IS_TEST_RUN"
|
||||
echo "sanitized-tag=$SANITIZED_TAG"
|
||||
echo "short-sha=$SHORT_SHA"
|
||||
@@ -600,7 +620,7 @@ jobs:
|
||||
latest=false
|
||||
tags: |
|
||||
type=raw,value=${{ needs.determine-builds.outputs.is-test-run == 'true' && format('web-{0}', needs.determine-builds.outputs.sanitized-tag) || github.ref_name }}
|
||||
type=raw,value=${{ needs.determine-builds.outputs.is-test-run != 'true' && needs.determine-builds.outputs.is-stable == 'true' && 'latest' || '' }}
|
||||
type=raw,value=${{ needs.determine-builds.outputs.is-test-run != 'true' && needs.determine-builds.outputs.is-latest == 'true' && 'latest' || '' }}
|
||||
type=raw,value=${{ needs.determine-builds.outputs.is-test-run != 'true' && env.EDGE_TAG == 'true' && 'edge' || '' }}
|
||||
type=raw,value=${{ needs.determine-builds.outputs.is-test-run != 'true' && needs.determine-builds.outputs.is-beta == 'true' && 'beta' || '' }}
|
||||
|
||||
@@ -1037,7 +1057,7 @@ jobs:
|
||||
latest=false
|
||||
tags: |
|
||||
type=raw,value=${{ needs.determine-builds.outputs.is-test-run == 'true' && format('backend-{0}', needs.determine-builds.outputs.sanitized-tag) || github.ref_name }}
|
||||
type=raw,value=${{ needs.determine-builds.outputs.is-test-run != 'true' && needs.determine-builds.outputs.is-stable-standalone == 'true' && 'latest' || '' }}
|
||||
type=raw,value=${{ needs.determine-builds.outputs.is-test-run != 'true' && needs.determine-builds.outputs.is-latest == 'true' && 'latest' || '' }}
|
||||
type=raw,value=${{ needs.determine-builds.outputs.is-test-run != 'true' && env.EDGE_TAG == 'true' && 'edge' || '' }}
|
||||
type=raw,value=${{ needs.determine-builds.outputs.is-test-run != 'true' && needs.determine-builds.outputs.is-beta-standalone == 'true' && 'beta' || '' }}
|
||||
|
||||
@@ -1473,7 +1493,7 @@ jobs:
|
||||
latest=false
|
||||
tags: |
|
||||
type=raw,value=${{ needs.determine-builds.outputs.is-test-run == 'true' && format('model-server-{0}', needs.determine-builds.outputs.sanitized-tag) || github.ref_name }}
|
||||
type=raw,value=${{ needs.determine-builds.outputs.is-test-run != 'true' && needs.determine-builds.outputs.is-stable-standalone == 'true' && 'latest' || '' }}
|
||||
type=raw,value=${{ needs.determine-builds.outputs.is-test-run != 'true' && needs.determine-builds.outputs.is-latest == 'true' && 'latest' || '' }}
|
||||
type=raw,value=${{ needs.determine-builds.outputs.is-test-run != 'true' && env.EDGE_TAG == 'true' && 'edge' || '' }}
|
||||
type=raw,value=${{ needs.determine-builds.outputs.is-test-run != 'true' && needs.determine-builds.outputs.is-beta-standalone == 'true' && 'beta' || '' }}
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""add multi-model columns to chat_message
|
||||
|
||||
Revision ID: a3f8b2c1d4e5
|
||||
Revises: 27fb147a843f
|
||||
Create Date: 2026-03-12 10:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "a3f8b2c1d4e5"
|
||||
down_revision = "27fb147a843f"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"chat_message",
|
||||
sa.Column(
|
||||
"preferred_response_id",
|
||||
sa.Integer(),
|
||||
sa.ForeignKey("chat_message.id"),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"chat_message",
|
||||
sa.Column(
|
||||
"model_display_name",
|
||||
sa.String(),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("chat_message", "model_display_name")
|
||||
op.drop_column("chat_message", "preferred_response_id")
|
||||
@@ -8,6 +8,7 @@ from onyx.configs.constants import MessageType
|
||||
from onyx.context.search.models import SearchDoc
|
||||
from onyx.file_store.models import InMemoryChatFile
|
||||
from onyx.server.query_and_chat.models import MessageResponseIDInfo
|
||||
from onyx.server.query_and_chat.models import MultiModelMessageResponseIDInfo
|
||||
from onyx.server.query_and_chat.streaming_models import CitationInfo
|
||||
from onyx.server.query_and_chat.streaming_models import GeneratedImage
|
||||
from onyx.server.query_and_chat.streaming_models import Packet
|
||||
@@ -35,7 +36,13 @@ class CreateChatSessionID(BaseModel):
|
||||
chat_session_id: UUID
|
||||
|
||||
|
||||
AnswerStreamPart = Packet | MessageResponseIDInfo | StreamingError | CreateChatSessionID
|
||||
AnswerStreamPart = (
|
||||
Packet
|
||||
| MessageResponseIDInfo
|
||||
| MultiModelMessageResponseIDInfo
|
||||
| StreamingError
|
||||
| CreateChatSessionID
|
||||
)
|
||||
|
||||
AnswerStream = Iterator[AnswerStreamPart]
|
||||
|
||||
|
||||
@@ -2622,6 +2622,18 @@ class ChatMessage(Base):
|
||||
ForeignKey("chat_message.id"), nullable=True
|
||||
)
|
||||
|
||||
# For multi-model turns: the user message points to which assistant response
|
||||
# was selected as the preferred one to continue the conversation with.
|
||||
# Only set on user messages that triggered a multi-model generation.
|
||||
preferred_response_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("chat_message.id"), nullable=True
|
||||
)
|
||||
|
||||
# The display name of the model that generated this assistant message
|
||||
# (e.g. "GPT-4", "Claude Opus"). Used on session reload to label
|
||||
# multi-model response panels and <> navigation arrows.
|
||||
model_display_name: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
|
||||
# Only set on summary messages - the ID of the last message included in this summary
|
||||
# Used for chat history compression
|
||||
last_summarized_message_id: Mapped[int | None] = mapped_column(
|
||||
@@ -2696,6 +2708,12 @@ class ChatMessage(Base):
|
||||
remote_side="ChatMessage.id",
|
||||
)
|
||||
|
||||
preferred_response: Mapped["ChatMessage | None"] = relationship(
|
||||
"ChatMessage",
|
||||
foreign_keys=[preferred_response_id],
|
||||
remote_side="ChatMessage.id",
|
||||
)
|
||||
|
||||
# Chat messages only need to know their immediate tool call children
|
||||
# If there are nested tool calls, they are stored in the tool_call_children relationship.
|
||||
tool_calls: Mapped[list["ToolCall"] | None] = relationship(
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import cast
|
||||
|
||||
@@ -49,8 +46,6 @@ from onyx.onyxbot.slack.utils import remove_slack_text_interactions
|
||||
from onyx.onyxbot.slack.utils import translate_vespa_highlight_to_slack
|
||||
from onyx.utils.text_processing import decode_escapes
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MAX_BLURB_LEN = 45
|
||||
|
||||
|
||||
@@ -81,99 +76,6 @@ def get_feedback_reminder_blocks(thread_link: str, include_followup: bool) -> Bl
|
||||
return SectionBlock(text=text)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CodeSnippet:
|
||||
"""A code block extracted from the answer to be uploaded as a Slack file."""
|
||||
|
||||
code: str
|
||||
language: str
|
||||
filename: str
|
||||
|
||||
|
||||
_SECTION_BLOCK_LIMIT = 3000
|
||||
|
||||
# Matches fenced code blocks: ```lang\n...\n```
|
||||
# The opening fence must start at the beginning of a line (^), may have an
|
||||
# optional language specifier (\w*), followed by a newline. The closing fence
|
||||
# is ``` at the beginning of a line. We also handle an optional trailing
|
||||
# newline on the closing fence line to be robust against different formatting.
|
||||
_CODE_FENCE_RE = re.compile(
|
||||
r"^```(\w*)\n(.*?)^```\s*$",
|
||||
re.MULTILINE | re.DOTALL,
|
||||
)
|
||||
|
||||
|
||||
def _extract_code_snippets(
|
||||
text: str, limit: int = _SECTION_BLOCK_LIMIT
|
||||
) -> tuple[str, list[CodeSnippet]]:
|
||||
"""Extract code blocks that would push the text over *limit*.
|
||||
|
||||
Returns (cleaned_text, snippets) where *cleaned_text* has large code
|
||||
blocks replaced with a placeholder and *snippets* contains the extracted
|
||||
code to be uploaded as Slack file snippets.
|
||||
|
||||
Uses a two-pass approach: first collect all matches, then decide which
|
||||
to extract based on cumulative removal so each decision accounts for
|
||||
previously extracted blocks.
|
||||
|
||||
Pass *limit=0* to force-extract ALL code blocks unconditionally.
|
||||
"""
|
||||
if limit > 0 and len(text) <= limit:
|
||||
return text, []
|
||||
|
||||
# Pass 1: collect all code-fence matches
|
||||
matches = list(_CODE_FENCE_RE.finditer(text))
|
||||
if not matches:
|
||||
return text, []
|
||||
|
||||
# Pass 2: decide which blocks to extract, accounting for cumulative removal.
|
||||
# Only extract if the text is still over the limit OR the block is very large.
|
||||
# With limit=0, extract everything unconditionally.
|
||||
extract_indices: set[int] = set()
|
||||
removed_chars = 0
|
||||
for i, match in enumerate(matches):
|
||||
full_block = match.group(0)
|
||||
if limit == 0:
|
||||
extract_indices.add(i)
|
||||
removed_chars += len(full_block)
|
||||
else:
|
||||
current_len = len(text) - removed_chars
|
||||
if current_len > limit and current_len - len(full_block) <= limit:
|
||||
extract_indices.add(i)
|
||||
removed_chars += len(full_block)
|
||||
elif len(full_block) > limit // 2:
|
||||
extract_indices.add(i)
|
||||
removed_chars += len(full_block)
|
||||
|
||||
if not extract_indices:
|
||||
return text, []
|
||||
|
||||
# Build cleaned text and snippets by processing matches in reverse
|
||||
# so character offsets remain valid.
|
||||
snippets: list[CodeSnippet] = []
|
||||
cleaned = text
|
||||
for i in sorted(extract_indices, reverse=True):
|
||||
match = matches[i]
|
||||
lang = match.group(1) or ""
|
||||
code = match.group(2)
|
||||
ext = lang if lang else "txt"
|
||||
snippets.append(
|
||||
CodeSnippet(
|
||||
code=code.strip(),
|
||||
language=lang or "text",
|
||||
filename=f"code_{len(extract_indices) - len(snippets)}.{ext}",
|
||||
)
|
||||
)
|
||||
cleaned = cleaned[: match.start()] + cleaned[match.end() :]
|
||||
|
||||
# Snippets were appended in reverse order — flip to match document order
|
||||
snippets.reverse()
|
||||
|
||||
# Clean up any triple+ blank lines left by extraction
|
||||
cleaned = re.sub(r"\n{3,}", "\n\n", cleaned).strip()
|
||||
return cleaned, snippets
|
||||
|
||||
|
||||
def _split_text(text: str, limit: int = 3000) -> list[str]:
|
||||
if len(text) <= limit:
|
||||
return [text]
|
||||
@@ -515,7 +417,7 @@ def _build_citations_blocks(
|
||||
|
||||
def _build_main_response_blocks(
|
||||
answer: ChatBasicResponse,
|
||||
) -> tuple[list[Block], list[CodeSnippet]]:
|
||||
) -> list[Block]:
|
||||
# TODO: add back in later when auto-filtering is implemented
|
||||
# if (
|
||||
# retrieval_info.applied_time_cutoff
|
||||
@@ -546,45 +448,9 @@ def _build_main_response_blocks(
|
||||
# replaces markdown links with slack format links
|
||||
formatted_answer = format_slack_message(answer.answer)
|
||||
answer_processed = decode_escapes(remove_slack_text_interactions(formatted_answer))
|
||||
answer_blocks = [SectionBlock(text=text) for text in _split_text(answer_processed)]
|
||||
|
||||
# Extract large code blocks as snippets to upload separately,
|
||||
# avoiding broken code fences when splitting across SectionBlocks.
|
||||
cleaned_text, code_snippets = _extract_code_snippets(answer_processed)
|
||||
logger.info(
|
||||
"Code extraction: input=%d chars, cleaned=%d chars, snippets=%d",
|
||||
len(answer_processed),
|
||||
len(cleaned_text),
|
||||
len(code_snippets),
|
||||
)
|
||||
|
||||
if len(cleaned_text) <= _SECTION_BLOCK_LIMIT:
|
||||
answer_blocks = [SectionBlock(text=cleaned_text)]
|
||||
elif "```" not in cleaned_text:
|
||||
# No code fences — safe to split at word boundaries.
|
||||
answer_blocks = [
|
||||
SectionBlock(text=text)
|
||||
for text in _split_text(cleaned_text, limit=_SECTION_BLOCK_LIMIT)
|
||||
]
|
||||
else:
|
||||
# Text still has code fences after extraction and exceeds the
|
||||
# SectionBlock limit. Splitting would break the fences, so fall
|
||||
# back to uploading the entire remaining code as another snippet
|
||||
# and keeping only the prose in the blocks.
|
||||
logger.warning(
|
||||
"Cleaned text still has code fences (%d chars); "
|
||||
"force-extracting remaining code blocks",
|
||||
len(cleaned_text),
|
||||
)
|
||||
remaining_cleaned, remaining_snippets = _extract_code_snippets(
|
||||
cleaned_text, limit=0
|
||||
)
|
||||
code_snippets.extend(remaining_snippets)
|
||||
answer_blocks = [
|
||||
SectionBlock(text=text)
|
||||
for text in _split_text(remaining_cleaned, limit=_SECTION_BLOCK_LIMIT)
|
||||
]
|
||||
|
||||
return cast(list[Block], answer_blocks), code_snippets
|
||||
return cast(list[Block], answer_blocks)
|
||||
|
||||
|
||||
def _build_continue_in_web_ui_block(
|
||||
@@ -665,13 +531,10 @@ def build_slack_response_blocks(
|
||||
skip_ai_feedback: bool = False,
|
||||
offer_ephemeral_publication: bool = False,
|
||||
skip_restated_question: bool = False,
|
||||
) -> tuple[list[Block], list[CodeSnippet]]:
|
||||
) -> list[Block]:
|
||||
"""
|
||||
This function is a top level function that builds all the blocks for the Slack response.
|
||||
It also handles combining all the blocks together.
|
||||
|
||||
Returns (blocks, code_snippets) where code_snippets should be uploaded
|
||||
as Slack file snippets in the same thread.
|
||||
"""
|
||||
# If called with the OnyxBot slash command, the question is lost so we have to reshow it
|
||||
if not skip_restated_question:
|
||||
@@ -681,7 +544,7 @@ def build_slack_response_blocks(
|
||||
else:
|
||||
restate_question_block = []
|
||||
|
||||
answer_blocks, code_snippets = _build_main_response_blocks(answer)
|
||||
answer_blocks = _build_main_response_blocks(answer)
|
||||
|
||||
web_follow_up_block = []
|
||||
if channel_conf and channel_conf.get("show_continue_in_web_ui"):
|
||||
@@ -747,4 +610,4 @@ def build_slack_response_blocks(
|
||||
+ follow_up_block
|
||||
)
|
||||
|
||||
return all_blocks, code_snippets
|
||||
return all_blocks
|
||||
|
||||
@@ -282,7 +282,7 @@ def handle_publish_ephemeral_message_button(
|
||||
logger.error(f"Failed to send webhook: {e}")
|
||||
|
||||
# remove handling of empheremal block and add AI feedback.
|
||||
all_blocks, _ = build_slack_response_blocks(
|
||||
all_blocks = build_slack_response_blocks(
|
||||
answer=onyx_bot_answer,
|
||||
message_info=slack_message_info,
|
||||
channel_conf=channel_conf,
|
||||
@@ -311,7 +311,7 @@ def handle_publish_ephemeral_message_button(
|
||||
elif action_id == KEEP_TO_YOURSELF_ACTION_ID:
|
||||
# Keep as ephemeral message in channel or thread, but remove the publish button and add feedback button
|
||||
|
||||
changed_blocks, _ = build_slack_response_blocks(
|
||||
changed_blocks = build_slack_response_blocks(
|
||||
answer=onyx_bot_answer,
|
||||
message_info=slack_message_info,
|
||||
channel_conf=channel_conf,
|
||||
|
||||
@@ -25,7 +25,6 @@ from onyx.db.models import User
|
||||
from onyx.db.persona import get_persona_by_id
|
||||
from onyx.db.users import get_user_by_email
|
||||
from onyx.onyxbot.slack.blocks import build_slack_response_blocks
|
||||
from onyx.onyxbot.slack.blocks import CodeSnippet
|
||||
from onyx.onyxbot.slack.constants import SLACK_CHANNEL_REF_PATTERN
|
||||
from onyx.onyxbot.slack.handlers.utils import send_team_member_message
|
||||
from onyx.onyxbot.slack.models import SlackMessageInfo
|
||||
@@ -135,65 +134,6 @@ def build_slack_context_str(
|
||||
return slack_context_str + "\n\n".join(message_strs)
|
||||
|
||||
|
||||
# Normalize common LLM language aliases to Slack's expected snippet_type values.
|
||||
# Slack silently falls back to plain text for unrecognized types, so this map
|
||||
# only needs to cover the most common mismatches.
|
||||
_SNIPPET_TYPE_MAP: dict[str, str] = {
|
||||
"py": "python",
|
||||
"js": "javascript",
|
||||
"ts": "typescript",
|
||||
"tsx": "typescript",
|
||||
"jsx": "javascript",
|
||||
"sh": "shell",
|
||||
"bash": "shell",
|
||||
"zsh": "shell",
|
||||
"yml": "yaml",
|
||||
"rb": "ruby",
|
||||
"rs": "rust",
|
||||
"cs": "csharp",
|
||||
"md": "markdown",
|
||||
"txt": "text",
|
||||
"text": "plain_text",
|
||||
}
|
||||
|
||||
|
||||
def _upload_code_snippets(
|
||||
client: WebClient,
|
||||
channel: str,
|
||||
thread_ts: str,
|
||||
snippets: list[CodeSnippet],
|
||||
logger: OnyxLoggingAdapter,
|
||||
receiver_ids: list[str] | None = None,
|
||||
send_as_ephemeral: bool | None = None,
|
||||
) -> None:
|
||||
"""Upload extracted code blocks as Slack file snippets in the thread."""
|
||||
for snippet in snippets:
|
||||
try:
|
||||
snippet_type = _SNIPPET_TYPE_MAP.get(snippet.language, snippet.language)
|
||||
client.files_upload_v2(
|
||||
channel=channel,
|
||||
thread_ts=thread_ts,
|
||||
content=snippet.code,
|
||||
filename=snippet.filename,
|
||||
snippet_type=snippet_type,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
f"Failed to upload code snippet {snippet.filename}, "
|
||||
"falling back to inline code block"
|
||||
)
|
||||
# Fall back to posting as a regular message with code fences,
|
||||
# preserving the same visibility as the primary response.
|
||||
respond_in_thread_or_channel(
|
||||
client=client,
|
||||
channel=channel,
|
||||
receiver_ids=receiver_ids,
|
||||
text=f"```{snippet.language}\n{snippet.code}\n```",
|
||||
thread_ts=thread_ts,
|
||||
send_as_ephemeral=send_as_ephemeral,
|
||||
)
|
||||
|
||||
|
||||
def handle_regular_answer(
|
||||
message_info: SlackMessageInfo,
|
||||
slack_channel_config: SlackChannelConfig,
|
||||
@@ -447,7 +387,7 @@ def handle_regular_answer(
|
||||
offer_ephemeral_publication = False
|
||||
skip_ai_feedback = False
|
||||
|
||||
all_blocks, code_snippets = build_slack_response_blocks(
|
||||
all_blocks = build_slack_response_blocks(
|
||||
message_info=message_info,
|
||||
answer=answer,
|
||||
channel_conf=channel_conf,
|
||||
@@ -474,20 +414,6 @@ def handle_regular_answer(
|
||||
send_as_ephemeral=send_as_ephemeral,
|
||||
)
|
||||
|
||||
# Upload extracted code blocks as Slack file snippets so they
|
||||
# render as collapsible, syntax-highlighted blocks in the thread.
|
||||
snippet_thread_ts = target_thread_ts or message_ts_to_respond_to
|
||||
if code_snippets and snippet_thread_ts:
|
||||
_upload_code_snippets(
|
||||
client=client,
|
||||
channel=channel,
|
||||
thread_ts=snippet_thread_ts,
|
||||
snippets=code_snippets,
|
||||
logger=logger,
|
||||
receiver_ids=target_receiver_ids,
|
||||
send_as_ephemeral=send_as_ephemeral,
|
||||
)
|
||||
|
||||
# For DM (ephemeral message), we need to create a thread via a normal message so the user can see
|
||||
# the ephemeral message. This also will give the user a notification which ephemeral message does not.
|
||||
# if there is no message_ts_to_respond_to, and we have made it this far, then this is a /onyx message
|
||||
|
||||
@@ -41,6 +41,16 @@ class MessageResponseIDInfo(BaseModel):
|
||||
reserved_assistant_message_id: int
|
||||
|
||||
|
||||
class MultiModelMessageResponseIDInfo(BaseModel):
|
||||
"""Sent at the start of a multi-model streaming response.
|
||||
Contains the user message ID and the reserved assistant message IDs
|
||||
for each model being run in parallel."""
|
||||
|
||||
user_message_id: int | None
|
||||
reserved_assistant_message_ids: list[int]
|
||||
model_names: list[str]
|
||||
|
||||
|
||||
class SourceTag(Tag):
|
||||
source: DocumentSource
|
||||
|
||||
@@ -86,6 +96,10 @@ class SendMessageRequest(BaseModel):
|
||||
message: str
|
||||
|
||||
llm_override: LLMOverride | None = None
|
||||
# For multi-model mode: up to 3 LLM overrides to run in parallel.
|
||||
# When provided with >1 entry, triggers multi-model streaming.
|
||||
# Backward-compat: if only `llm_override` is set, single-model path is used.
|
||||
llm_overrides: list[LLMOverride] | None = None
|
||||
# Test-only override for deterministic LiteLLM mock responses.
|
||||
mock_llm_response: str | None = None
|
||||
|
||||
@@ -211,6 +225,10 @@ class ChatMessageDetail(BaseModel):
|
||||
error: str | None = None
|
||||
current_feedback: str | None = None # "like" | "dislike" | null
|
||||
processing_duration_seconds: float | None = None
|
||||
# For multi-model turns: the preferred assistant response ID (set on user messages only)
|
||||
preferred_response_id: int | None = None
|
||||
# The display name of the model that generated this message (e.g. "GPT-4", "Claude Opus")
|
||||
model_display_name: str | None = None
|
||||
|
||||
def model_dump(self, *args: list, **kwargs: dict[str, Any]) -> dict[str, Any]: # type: ignore
|
||||
initial_dict = super().model_dump(mode="json", *args, **kwargs) # type: ignore
|
||||
|
||||
@@ -8,3 +8,6 @@ class Placement(BaseModel):
|
||||
tab_index: int = 0
|
||||
# Used for tools/agents that call other tools, this currently doesn't support nested agents but can be added later
|
||||
sub_turn_index: int | None = None
|
||||
# For multi-model streaming: identifies which model (0, 1, 2) this packet belongs to.
|
||||
# None for single-model (default) responses.
|
||||
model_index: int | None = None
|
||||
|
||||
112
backend/tests/unit/onyx/chat/test_multi_model_types.py
Normal file
112
backend/tests/unit/onyx/chat/test_multi_model_types.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""Unit tests for multi-model schema and Pydantic model additions."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from onyx.configs.constants import MessageType
|
||||
from onyx.llm.override_models import LLMOverride
|
||||
from onyx.server.query_and_chat.models import ChatMessageDetail
|
||||
from onyx.server.query_and_chat.models import MultiModelMessageResponseIDInfo
|
||||
from onyx.server.query_and_chat.models import SendMessageRequest
|
||||
from onyx.server.query_and_chat.placement import Placement
|
||||
|
||||
|
||||
def test_placement_model_index_default_none() -> None:
|
||||
p = Placement(turn_index=0)
|
||||
assert p.model_index is None
|
||||
|
||||
|
||||
def test_placement_model_index_set() -> None:
|
||||
p = Placement(turn_index=0, model_index=2)
|
||||
assert p.model_index == 2
|
||||
|
||||
|
||||
def test_placement_serialization_with_model_index() -> None:
|
||||
p = Placement(turn_index=1, tab_index=0, model_index=1)
|
||||
data = p.model_dump()
|
||||
assert data["model_index"] == 1
|
||||
restored = Placement(**data)
|
||||
assert restored.model_index == 1
|
||||
|
||||
|
||||
def test_multi_model_message_response_id_info() -> None:
|
||||
info = MultiModelMessageResponseIDInfo(
|
||||
user_message_id=42,
|
||||
reserved_assistant_message_ids=[100, 101, 102],
|
||||
model_names=["gpt-4", "claude-3-opus", "gemini-pro"],
|
||||
)
|
||||
data = info.model_dump()
|
||||
assert data["user_message_id"] == 42
|
||||
assert len(data["reserved_assistant_message_ids"]) == 3
|
||||
assert len(data["model_names"]) == 3
|
||||
|
||||
|
||||
def test_multi_model_message_response_id_info_null_user() -> None:
|
||||
info = MultiModelMessageResponseIDInfo(
|
||||
user_message_id=None,
|
||||
reserved_assistant_message_ids=[10],
|
||||
model_names=["gpt-4"],
|
||||
)
|
||||
assert info.user_message_id is None
|
||||
|
||||
|
||||
def test_send_message_request_llm_overrides_none_by_default() -> None:
|
||||
req = SendMessageRequest(
|
||||
message="hello",
|
||||
chat_session_id="00000000-0000-0000-0000-000000000001",
|
||||
)
|
||||
assert req.llm_overrides is None
|
||||
assert req.llm_override is None
|
||||
|
||||
|
||||
def test_send_message_request_with_llm_overrides() -> None:
|
||||
overrides = [
|
||||
LLMOverride(model_provider="openai", model_version="gpt-4"),
|
||||
LLMOverride(model_provider="anthropic", model_version="claude-3-opus"),
|
||||
]
|
||||
req = SendMessageRequest(
|
||||
message="compare these",
|
||||
chat_session_id="00000000-0000-0000-0000-000000000001",
|
||||
llm_overrides=overrides,
|
||||
)
|
||||
assert req.llm_overrides is not None
|
||||
assert len(req.llm_overrides) == 2
|
||||
|
||||
|
||||
def test_send_message_request_backward_compat_single_override() -> None:
|
||||
"""Existing single llm_override still works alongside new llm_overrides field."""
|
||||
req = SendMessageRequest(
|
||||
message="single model",
|
||||
chat_session_id="00000000-0000-0000-0000-000000000001",
|
||||
llm_override=LLMOverride(model_provider="openai", model_version="gpt-4"),
|
||||
)
|
||||
assert req.llm_override is not None
|
||||
assert req.llm_overrides is None
|
||||
|
||||
|
||||
def test_chat_message_detail_multi_model_fields_default_none() -> None:
|
||||
detail = ChatMessageDetail(
|
||||
message_id=1,
|
||||
message="hello",
|
||||
message_type=MessageType.USER,
|
||||
time_sent=datetime.now(),
|
||||
files=[],
|
||||
)
|
||||
assert detail.preferred_response_id is None
|
||||
assert detail.model_display_name is None
|
||||
|
||||
|
||||
def test_chat_message_detail_multi_model_fields_set() -> None:
|
||||
detail = ChatMessageDetail(
|
||||
message_id=1,
|
||||
message="response from gpt-4",
|
||||
message_type=MessageType.ASSISTANT,
|
||||
time_sent=datetime.now(),
|
||||
files=[],
|
||||
preferred_response_id=42,
|
||||
model_display_name="GPT-4",
|
||||
)
|
||||
assert detail.preferred_response_id == 42
|
||||
assert detail.model_display_name == "GPT-4"
|
||||
data = detail.model_dump()
|
||||
assert data["preferred_response_id"] == 42
|
||||
assert data["model_display_name"] == "GPT-4"
|
||||
@@ -7,9 +7,6 @@ import timeago # type: ignore
|
||||
from onyx.configs.constants import DocumentSource
|
||||
from onyx.context.search.models import SavedSearchDoc
|
||||
from onyx.onyxbot.slack.blocks import _build_documents_blocks
|
||||
from onyx.onyxbot.slack.blocks import _extract_code_snippets
|
||||
from onyx.onyxbot.slack.blocks import _split_text
|
||||
from onyx.onyxbot.slack.handlers.handle_regular_answer import _SNIPPET_TYPE_MAP
|
||||
|
||||
|
||||
def _make_saved_doc(updated_at: datetime | None) -> SavedSearchDoc:
|
||||
@@ -72,148 +69,3 @@ def test_build_documents_blocks_formats_naive_timestamp(
|
||||
formatted_timestamp: datetime = captured["doc"]
|
||||
expected_timestamp: datetime = naive_timestamp.replace(tzinfo=pytz.utc)
|
||||
assert formatted_timestamp == expected_timestamp
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _split_text tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSplitText:
|
||||
def test_short_text_returns_single_chunk(self) -> None:
|
||||
result = _split_text("hello world", limit=100)
|
||||
assert result == ["hello world"]
|
||||
|
||||
def test_splits_at_space_boundary(self) -> None:
|
||||
text = "aaa bbb ccc ddd"
|
||||
result = _split_text(text, limit=8)
|
||||
assert len(result) >= 2
|
||||
|
||||
def test_no_code_fences_splits_normally(self) -> None:
|
||||
text = "word " * 100 # 500 chars
|
||||
result = _split_text(text, limit=100)
|
||||
assert len(result) >= 5
|
||||
for chunk in result:
|
||||
assert "```" not in chunk
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _extract_code_snippets tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestExtractCodeSnippets:
|
||||
def test_short_text_no_extraction(self) -> None:
|
||||
text = "short answer with ```python\nprint('hi')\n``` inline"
|
||||
cleaned, snippets = _extract_code_snippets(text, limit=3000)
|
||||
assert cleaned == text
|
||||
assert snippets == []
|
||||
|
||||
def test_large_code_block_extracted(self) -> None:
|
||||
code = "x = 1\n" * 200 # ~1200 chars of code
|
||||
text = f"Here is the solution:\n```python\n{code}```\nHope that helps!"
|
||||
cleaned, snippets = _extract_code_snippets(text, limit=200)
|
||||
|
||||
assert len(snippets) == 1
|
||||
assert snippets[0].language == "python"
|
||||
assert snippets[0].filename == "code_1.python"
|
||||
assert "x = 1" in snippets[0].code
|
||||
# Code block should be removed from cleaned text
|
||||
assert "```" not in cleaned
|
||||
assert "Here is the solution" in cleaned
|
||||
assert "Hope that helps!" in cleaned
|
||||
|
||||
def test_multiple_code_blocks_only_large_ones_extracted(self) -> None:
|
||||
small_code = "print('hi')"
|
||||
large_code = "x = 1\n" * 300
|
||||
text = (
|
||||
f"First:\n```python\n{small_code}\n```\n"
|
||||
f"Second:\n```javascript\n{large_code}\n```\n"
|
||||
"Done!"
|
||||
)
|
||||
cleaned, snippets = _extract_code_snippets(text, limit=500)
|
||||
|
||||
# The large block should be extracted
|
||||
assert len(snippets) >= 1
|
||||
langs = [s.language for s in snippets]
|
||||
assert "javascript" in langs
|
||||
|
||||
def test_language_specifier_captured(self) -> None:
|
||||
code = "fn main() {}\n" * 100
|
||||
text = f"```rust\n{code}```"
|
||||
_, snippets = _extract_code_snippets(text, limit=100)
|
||||
|
||||
assert len(snippets) == 1
|
||||
assert snippets[0].language == "rust"
|
||||
assert snippets[0].filename == "code_1.rust"
|
||||
|
||||
def test_no_language_defaults_to_text(self) -> None:
|
||||
code = "some output\n" * 100
|
||||
text = f"```\n{code}```"
|
||||
_, snippets = _extract_code_snippets(text, limit=100)
|
||||
|
||||
assert len(snippets) == 1
|
||||
assert snippets[0].language == "text"
|
||||
assert snippets[0].filename == "code_1.txt"
|
||||
|
||||
def test_cleaned_text_has_no_triple_blank_lines(self) -> None:
|
||||
code = "x = 1\n" * 200
|
||||
text = f"Before\n\n```python\n{code}```\n\nAfter"
|
||||
cleaned, _ = _extract_code_snippets(text, limit=100)
|
||||
|
||||
assert "\n\n\n" not in cleaned
|
||||
|
||||
def test_multiple_blocks_cumulative_removal(self) -> None:
|
||||
"""When multiple code blocks exist, extraction decisions should
|
||||
account for previously extracted blocks (two-pass logic).
|
||||
Blocks must be smaller than limit//2 so the 'very large block'
|
||||
override doesn't trigger — we're testing the cumulative logic only."""
|
||||
# Each fenced block is ~103 chars (```python\n + 15*6 + ```)
|
||||
block_a = "a = 1\n" * 15 # 90 chars of code
|
||||
block_b = "b = 2\n" * 15 # 90 chars of code
|
||||
prose = "x" * 200
|
||||
# Total: ~200 + 103 + 103 + overhead ≈ 420 chars
|
||||
# limit=300, limit//2=150. Each block (~103) < 150, so only
|
||||
# the cumulative check applies. Removing block_a (~103 chars)
|
||||
# brings us to ~317 > 300, so block_b should also be extracted.
|
||||
# But with limit=350: removing block_a → ~317 ≤ 350, stop.
|
||||
text = f"{prose}\n```python\n{block_a}```\n```python\n{block_b}```\nEnd"
|
||||
cleaned, snippets = _extract_code_snippets(text, limit=350)
|
||||
|
||||
# After extracting block_a the text is ≤ 350, so block_b stays.
|
||||
assert len(snippets) == 1
|
||||
assert snippets[0].filename == "code_1.python"
|
||||
# block_b should still be in the cleaned text
|
||||
assert "b = 2" in cleaned
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _SNIPPET_TYPE_MAP tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSnippetTypeMap:
|
||||
@pytest.mark.parametrize(
|
||||
"alias,expected",
|
||||
[
|
||||
("py", "python"),
|
||||
("js", "javascript"),
|
||||
("ts", "typescript"),
|
||||
("tsx", "typescript"),
|
||||
("jsx", "javascript"),
|
||||
("sh", "shell"),
|
||||
("bash", "shell"),
|
||||
("yml", "yaml"),
|
||||
("rb", "ruby"),
|
||||
("rs", "rust"),
|
||||
("cs", "csharp"),
|
||||
("md", "markdown"),
|
||||
("text", "plain_text"),
|
||||
],
|
||||
)
|
||||
def test_common_aliases_normalized(self, alias: str, expected: str) -> None:
|
||||
assert _SNIPPET_TYPE_MAP[alias] == expected
|
||||
|
||||
def test_unknown_language_passes_through(self) -> None:
|
||||
unknown = "haskell"
|
||||
assert _SNIPPET_TYPE_MAP.get(unknown, unknown) == "haskell"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import useSWR from "swr";
|
||||
import { errorHandlingFetcher } from "@/lib/fetcher";
|
||||
import { NEXT_PUBLIC_CLOUD_ENABLED } from "@/lib/constants";
|
||||
@@ -112,11 +113,11 @@ export default function useAdminUsers() {
|
||||
const isLoading = acceptedLoading || invitedLoading || requestedLoading;
|
||||
const error = acceptedError ?? invitedError ?? requestedError;
|
||||
|
||||
function refresh() {
|
||||
const refresh = useCallback(() => {
|
||||
acceptedMutate();
|
||||
invitedMutate();
|
||||
requestedMutate();
|
||||
}
|
||||
}, [acceptedMutate, invitedMutate, requestedMutate]);
|
||||
|
||||
return { users, isLoading, error, refresh };
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { cn, noProp } from "@/lib/utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import LineItem, { LineItemProps } from "@/refresh-components/buttons/LineItem";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import type { IconProps } from "@opal/types";
|
||||
@@ -298,7 +298,10 @@ function InputSelectContent({
|
||||
)}
|
||||
sideOffset={4}
|
||||
position="popper"
|
||||
onMouseDown={noProp()}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.Viewport className="flex flex-col gap-1">
|
||||
|
||||
210
web/src/refresh-pages/admin/UsersPage/UserRowActions.tsx
Normal file
210
web/src/refresh-pages/admin/UsersPage/UserRowActions.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@opal/components";
|
||||
import { SvgMoreHorizontal, SvgXCircle, SvgTrash, SvgCheck } from "@opal/icons";
|
||||
import { Disabled } from "@opal/core";
|
||||
import Popover from "@/refresh-components/Popover";
|
||||
import ConfirmationModalLayout from "@/refresh-components/layouts/ConfirmationModalLayout";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { UserStatus } from "@/lib/types";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import { deactivateUser, activateUser, deleteUser } from "./svc";
|
||||
import type { UserRow } from "./interfaces";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ModalType = "deactivate" | "activate" | "delete" | null;
|
||||
|
||||
interface UserRowActionsProps {
|
||||
user: UserRow;
|
||||
onMutate: () => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function UserRowActions({
|
||||
user,
|
||||
onMutate,
|
||||
}: UserRowActionsProps) {
|
||||
const [modal, setModal] = useState<ModalType>(null);
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
async function handleAction(
|
||||
action: () => Promise<void>,
|
||||
successMessage: string
|
||||
) {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await action();
|
||||
onMutate();
|
||||
toast.success(successMessage);
|
||||
setModal(null);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "An error occurred");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Only show actions for accepted users (active or inactive).
|
||||
// Invited/requested users have no row actions in this PR.
|
||||
if (
|
||||
user.status !== UserStatus.ACTIVE &&
|
||||
user.status !== UserStatus.INACTIVE
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// SCIM-managed users cannot be modified from the UI — changes would be
|
||||
// overwritten on the next IdP sync.
|
||||
if (user.is_scim_synced) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
|
||||
<Popover.Trigger asChild>
|
||||
<Button prominence="tertiary" icon={SvgMoreHorizontal} />
|
||||
</Popover.Trigger>
|
||||
<Popover.Content align="end">
|
||||
<div className="flex flex-col gap-0.5 p-1">
|
||||
{user.status === UserStatus.ACTIVE ? (
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
icon={SvgXCircle}
|
||||
onClick={() => {
|
||||
setPopoverOpen(false);
|
||||
setModal("deactivate");
|
||||
}}
|
||||
>
|
||||
Deactivate User
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
icon={SvgCheck}
|
||||
onClick={() => {
|
||||
setPopoverOpen(false);
|
||||
setModal("activate");
|
||||
}}
|
||||
>
|
||||
Activate User
|
||||
</Button>
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
variant="danger"
|
||||
icon={SvgTrash}
|
||||
onClick={() => {
|
||||
setPopoverOpen(false);
|
||||
setModal("delete");
|
||||
}}
|
||||
>
|
||||
Delete User
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
|
||||
{modal === "deactivate" && (
|
||||
<ConfirmationModalLayout
|
||||
icon={SvgXCircle}
|
||||
title="Deactivate User"
|
||||
onClose={isSubmitting ? undefined : () => setModal(null)}
|
||||
submit={
|
||||
<Disabled disabled={isSubmitting}>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={async () => {
|
||||
await handleAction(
|
||||
() => deactivateUser(user.email),
|
||||
"User deactivated"
|
||||
);
|
||||
}}
|
||||
>
|
||||
Deactivate
|
||||
</Button>
|
||||
</Disabled>
|
||||
}
|
||||
>
|
||||
<Text as="p" text03>
|
||||
<Text as="span" text05>
|
||||
{user.email}
|
||||
</Text>{" "}
|
||||
will immediately lose access to Onyx. Their sessions and agents will
|
||||
be preserved. You can reactivate this account later.
|
||||
</Text>
|
||||
</ConfirmationModalLayout>
|
||||
)}
|
||||
|
||||
{modal === "activate" && (
|
||||
<ConfirmationModalLayout
|
||||
icon={SvgCheck}
|
||||
title="Activate User"
|
||||
onClose={isSubmitting ? undefined : () => setModal(null)}
|
||||
submit={
|
||||
<Disabled disabled={isSubmitting}>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
await handleAction(
|
||||
() => activateUser(user.email),
|
||||
"User activated"
|
||||
);
|
||||
}}
|
||||
>
|
||||
Activate
|
||||
</Button>
|
||||
</Disabled>
|
||||
}
|
||||
>
|
||||
<Text as="p" text03>
|
||||
<Text as="span" text05>
|
||||
{user.email}
|
||||
</Text>{" "}
|
||||
will regain access to Onyx.
|
||||
</Text>
|
||||
</ConfirmationModalLayout>
|
||||
)}
|
||||
|
||||
{modal === "delete" && (
|
||||
<ConfirmationModalLayout
|
||||
icon={SvgTrash}
|
||||
title="Delete User"
|
||||
onClose={isSubmitting ? undefined : () => setModal(null)}
|
||||
submit={
|
||||
<Disabled disabled={isSubmitting}>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={async () => {
|
||||
await handleAction(
|
||||
() => deleteUser(user.email),
|
||||
"User deleted"
|
||||
);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Disabled>
|
||||
}
|
||||
>
|
||||
<Text as="p" text03>
|
||||
<Text as="span" text05>
|
||||
{user.email}
|
||||
</Text>{" "}
|
||||
will be permanently removed from Onyx. All of their session history
|
||||
will be deleted. This cannot be undone.
|
||||
</Text>
|
||||
</ConfirmationModalLayout>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
|
||||
import useAdminUsers from "@/hooks/useAdminUsers";
|
||||
import useGroups from "@/hooks/useGroups";
|
||||
import UserFilters from "./UserFilters";
|
||||
import UserRowActions from "./UserRowActions";
|
||||
import type {
|
||||
UserRow,
|
||||
UserGroupInfo,
|
||||
@@ -133,50 +134,54 @@ function renderLastUpdatedColumn(value: string | null) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Columns (stable reference — defined at module scope)
|
||||
// Columns
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const tc = createTableColumns<UserRow>();
|
||||
|
||||
const columns = [
|
||||
tc.qualifier({
|
||||
content: "avatar-user",
|
||||
getInitials: (row) => getInitials(row.personal_name, row.email),
|
||||
selectable: false,
|
||||
}),
|
||||
tc.column("email", {
|
||||
header: "Name",
|
||||
weight: 22,
|
||||
minWidth: 140,
|
||||
cell: renderNameColumn,
|
||||
}),
|
||||
tc.column("groups", {
|
||||
header: "Groups",
|
||||
weight: 24,
|
||||
minWidth: 200,
|
||||
enableSorting: false,
|
||||
cell: renderGroupsColumn,
|
||||
}),
|
||||
tc.column("role", {
|
||||
header: "Account Type",
|
||||
weight: 16,
|
||||
minWidth: 180,
|
||||
cell: renderRoleColumn,
|
||||
}),
|
||||
tc.column("status", {
|
||||
header: "Status",
|
||||
weight: 14,
|
||||
minWidth: 100,
|
||||
cell: renderStatusColumn,
|
||||
}),
|
||||
tc.column("updated_at", {
|
||||
header: "Last Updated",
|
||||
weight: 14,
|
||||
minWidth: 100,
|
||||
cell: renderLastUpdatedColumn,
|
||||
}),
|
||||
tc.actions(),
|
||||
];
|
||||
function buildColumns(onMutate: () => void) {
|
||||
return [
|
||||
tc.qualifier({
|
||||
content: "avatar-user",
|
||||
getInitials: (row) => getInitials(row.personal_name, row.email),
|
||||
selectable: false,
|
||||
}),
|
||||
tc.column("email", {
|
||||
header: "Name",
|
||||
weight: 22,
|
||||
minWidth: 140,
|
||||
cell: renderNameColumn,
|
||||
}),
|
||||
tc.column("groups", {
|
||||
header: "Groups",
|
||||
weight: 24,
|
||||
minWidth: 200,
|
||||
enableSorting: false,
|
||||
cell: renderGroupsColumn,
|
||||
}),
|
||||
tc.column("role", {
|
||||
header: "Account Type",
|
||||
weight: 16,
|
||||
minWidth: 180,
|
||||
cell: renderRoleColumn,
|
||||
}),
|
||||
tc.column("status", {
|
||||
header: "Status",
|
||||
weight: 14,
|
||||
minWidth: 100,
|
||||
cell: renderStatusColumn,
|
||||
}),
|
||||
tc.column("updated_at", {
|
||||
header: "Last Updated",
|
||||
weight: 14,
|
||||
minWidth: 100,
|
||||
cell: renderLastUpdatedColumn,
|
||||
}),
|
||||
tc.actions({
|
||||
cell: (row) => <UserRowActions user={row} onMutate={onMutate} />,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
@@ -213,7 +218,9 @@ export default function UsersTable({
|
||||
[allGroups]
|
||||
);
|
||||
|
||||
const { users, isLoading, error } = useAdminUsers();
|
||||
const { users, isLoading, error, refresh } = useAdminUsers();
|
||||
|
||||
const columns = useMemo(() => buildColumns(refresh), [refresh]);
|
||||
|
||||
// Client-side filtering
|
||||
const filteredUsers = useMemo(() => {
|
||||
|
||||
44
web/src/refresh-pages/admin/UsersPage/svc.ts
Normal file
44
web/src/refresh-pages/admin/UsersPage/svc.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
async function parseErrorDetail(
|
||||
res: Response,
|
||||
fallback: string
|
||||
): Promise<string> {
|
||||
try {
|
||||
const body = await res.json();
|
||||
return body?.detail ?? fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deactivateUser(email: string): Promise<void> {
|
||||
const res = await fetch("/api/manage/admin/deactivate-user", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ user_email: email }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(await parseErrorDetail(res, "Failed to deactivate user"));
|
||||
}
|
||||
}
|
||||
|
||||
export async function activateUser(email: string): Promise<void> {
|
||||
const res = await fetch("/api/manage/admin/activate-user", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ user_email: email }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(await parseErrorDetail(res, "Failed to activate user"));
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteUser(email: string): Promise<void> {
|
||||
const res = await fetch("/api/manage/admin/delete-user", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ user_email: email }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(await parseErrorDetail(res, "Failed to delete user"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user