mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-02-25 11:45:47 +00:00
Compare commits
18 Commits
ci_python_
...
v2.9.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8726b112fe | ||
|
|
92181d07b2 | ||
|
|
3a73f7fab2 | ||
|
|
7dabaca7cd | ||
|
|
dec4748825 | ||
|
|
072836cd86 | ||
|
|
2705b5fb0e | ||
|
|
37dcde4226 | ||
|
|
a765b5f622 | ||
|
|
5e093368d1 | ||
|
|
f945ab6b05 | ||
|
|
11b7a22404 | ||
|
|
8e34f944cc | ||
|
|
32606dc752 | ||
|
|
1f6c4b40bf | ||
|
|
1943f1c745 | ||
|
|
82460729a6 | ||
|
|
c445e6a8c0 |
@@ -225,7 +225,6 @@ def do_run_migrations(
|
||||
) -> None:
|
||||
if create_schema:
|
||||
connection.execute(text(f'CREATE SCHEMA IF NOT EXISTS "{schema_name}"'))
|
||||
connection.execute(text("COMMIT"))
|
||||
|
||||
connection.execute(text(f'SET search_path TO "{schema_name}"'))
|
||||
|
||||
@@ -309,6 +308,7 @@ async def run_async_migrations() -> None:
|
||||
schema_name=schema,
|
||||
create_schema=create_schema,
|
||||
)
|
||||
await connection.commit()
|
||||
except Exception as e:
|
||||
logger.error(f"Error migrating schema {schema}: {e}")
|
||||
if not continue_on_error:
|
||||
@@ -346,6 +346,7 @@ async def run_async_migrations() -> None:
|
||||
schema_name=schema,
|
||||
create_schema=create_schema,
|
||||
)
|
||||
await connection.commit()
|
||||
except Exception as e:
|
||||
logger.error(f"Error migrating schema {schema}: {e}")
|
||||
if not continue_on_error:
|
||||
|
||||
@@ -85,103 +85,122 @@ class UserRow(NamedTuple):
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# Start transaction
|
||||
conn.execute(sa.text("BEGIN"))
|
||||
# Step 1: Create or update the unified assistant (ID 0)
|
||||
search_assistant = conn.execute(
|
||||
sa.text("SELECT * FROM persona WHERE id = 0")
|
||||
).fetchone()
|
||||
|
||||
try:
|
||||
# Step 1: Create or update the unified assistant (ID 0)
|
||||
search_assistant = conn.execute(
|
||||
sa.text("SELECT * FROM persona WHERE id = 0")
|
||||
).fetchone()
|
||||
|
||||
if search_assistant:
|
||||
# Update existing Search assistant to be the unified assistant
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE persona
|
||||
SET name = :name,
|
||||
description = :description,
|
||||
system_prompt = :system_prompt,
|
||||
num_chunks = :num_chunks,
|
||||
is_default_persona = true,
|
||||
is_visible = true,
|
||||
deleted = false,
|
||||
display_priority = :display_priority,
|
||||
llm_filter_extraction = :llm_filter_extraction,
|
||||
llm_relevance_filter = :llm_relevance_filter,
|
||||
recency_bias = :recency_bias,
|
||||
chunks_above = :chunks_above,
|
||||
chunks_below = :chunks_below,
|
||||
datetime_aware = :datetime_aware,
|
||||
starter_messages = null
|
||||
WHERE id = 0
|
||||
"""
|
||||
),
|
||||
INSERT_DICT,
|
||||
)
|
||||
else:
|
||||
# Create new unified assistant with ID 0
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO persona (
|
||||
id, name, description, system_prompt, num_chunks,
|
||||
is_default_persona, is_visible, deleted, display_priority,
|
||||
llm_filter_extraction, llm_relevance_filter, recency_bias,
|
||||
chunks_above, chunks_below, datetime_aware, starter_messages,
|
||||
builtin_persona
|
||||
) VALUES (
|
||||
0, :name, :description, :system_prompt, :num_chunks,
|
||||
true, true, false, :display_priority, :llm_filter_extraction,
|
||||
:llm_relevance_filter, :recency_bias, :chunks_above, :chunks_below,
|
||||
:datetime_aware, null, true
|
||||
)
|
||||
"""
|
||||
),
|
||||
INSERT_DICT,
|
||||
)
|
||||
|
||||
# Step 2: Mark ALL builtin assistants as deleted (except the unified assistant ID 0)
|
||||
if search_assistant:
|
||||
# Update existing Search assistant to be the unified assistant
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE persona
|
||||
SET deleted = true, is_visible = false, is_default_persona = false
|
||||
WHERE builtin_persona = true AND id != 0
|
||||
SET name = :name,
|
||||
description = :description,
|
||||
system_prompt = :system_prompt,
|
||||
num_chunks = :num_chunks,
|
||||
is_default_persona = true,
|
||||
is_visible = true,
|
||||
deleted = false,
|
||||
display_priority = :display_priority,
|
||||
llm_filter_extraction = :llm_filter_extraction,
|
||||
llm_relevance_filter = :llm_relevance_filter,
|
||||
recency_bias = :recency_bias,
|
||||
chunks_above = :chunks_above,
|
||||
chunks_below = :chunks_below,
|
||||
datetime_aware = :datetime_aware,
|
||||
starter_messages = null
|
||||
WHERE id = 0
|
||||
"""
|
||||
)
|
||||
),
|
||||
INSERT_DICT,
|
||||
)
|
||||
else:
|
||||
# Create new unified assistant with ID 0
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO persona (
|
||||
id, name, description, system_prompt, num_chunks,
|
||||
is_default_persona, is_visible, deleted, display_priority,
|
||||
llm_filter_extraction, llm_relevance_filter, recency_bias,
|
||||
chunks_above, chunks_below, datetime_aware, starter_messages,
|
||||
builtin_persona
|
||||
) VALUES (
|
||||
0, :name, :description, :system_prompt, :num_chunks,
|
||||
true, true, false, :display_priority, :llm_filter_extraction,
|
||||
:llm_relevance_filter, :recency_bias, :chunks_above, :chunks_below,
|
||||
:datetime_aware, null, true
|
||||
)
|
||||
"""
|
||||
),
|
||||
INSERT_DICT,
|
||||
)
|
||||
|
||||
# Step 3: Add all built-in tools to the unified assistant
|
||||
# First, get the tool IDs for SearchTool, ImageGenerationTool, and WebSearchTool
|
||||
search_tool = conn.execute(
|
||||
sa.text("SELECT id FROM tool WHERE in_code_tool_id = 'SearchTool'")
|
||||
).fetchone()
|
||||
# Step 2: Mark ALL builtin assistants as deleted (except the unified assistant ID 0)
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE persona
|
||||
SET deleted = true, is_visible = false, is_default_persona = false
|
||||
WHERE builtin_persona = true AND id != 0
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
if not search_tool:
|
||||
raise ValueError(
|
||||
"SearchTool not found in database. Ensure tools migration has run first."
|
||||
)
|
||||
# Step 3: Add all built-in tools to the unified assistant
|
||||
# First, get the tool IDs for SearchTool, ImageGenerationTool, and WebSearchTool
|
||||
search_tool = conn.execute(
|
||||
sa.text("SELECT id FROM tool WHERE in_code_tool_id = 'SearchTool'")
|
||||
).fetchone()
|
||||
|
||||
image_gen_tool = conn.execute(
|
||||
sa.text("SELECT id FROM tool WHERE in_code_tool_id = 'ImageGenerationTool'")
|
||||
).fetchone()
|
||||
if not search_tool:
|
||||
raise ValueError(
|
||||
"SearchTool not found in database. Ensure tools migration has run first."
|
||||
)
|
||||
|
||||
if not image_gen_tool:
|
||||
raise ValueError(
|
||||
"ImageGenerationTool not found in database. Ensure tools migration has run first."
|
||||
)
|
||||
image_gen_tool = conn.execute(
|
||||
sa.text("SELECT id FROM tool WHERE in_code_tool_id = 'ImageGenerationTool'")
|
||||
).fetchone()
|
||||
|
||||
# WebSearchTool is optional - may not be configured
|
||||
web_search_tool = conn.execute(
|
||||
sa.text("SELECT id FROM tool WHERE in_code_tool_id = 'WebSearchTool'")
|
||||
).fetchone()
|
||||
if not image_gen_tool:
|
||||
raise ValueError(
|
||||
"ImageGenerationTool not found in database. Ensure tools migration has run first."
|
||||
)
|
||||
|
||||
# Clear existing tool associations for persona 0
|
||||
conn.execute(sa.text("DELETE FROM persona__tool WHERE persona_id = 0"))
|
||||
# WebSearchTool is optional - may not be configured
|
||||
web_search_tool = conn.execute(
|
||||
sa.text("SELECT id FROM tool WHERE in_code_tool_id = 'WebSearchTool'")
|
||||
).fetchone()
|
||||
|
||||
# Add tools to the unified assistant
|
||||
# Clear existing tool associations for persona 0
|
||||
conn.execute(sa.text("DELETE FROM persona__tool WHERE persona_id = 0"))
|
||||
|
||||
# Add tools to the unified assistant
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO persona__tool (persona_id, tool_id)
|
||||
VALUES (0, :tool_id)
|
||||
ON CONFLICT DO NOTHING
|
||||
"""
|
||||
),
|
||||
{"tool_id": search_tool[0]},
|
||||
)
|
||||
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO persona__tool (persona_id, tool_id)
|
||||
VALUES (0, :tool_id)
|
||||
ON CONFLICT DO NOTHING
|
||||
"""
|
||||
),
|
||||
{"tool_id": image_gen_tool[0]},
|
||||
)
|
||||
|
||||
if web_search_tool:
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
@@ -190,191 +209,148 @@ def upgrade() -> None:
|
||||
ON CONFLICT DO NOTHING
|
||||
"""
|
||||
),
|
||||
{"tool_id": search_tool[0]},
|
||||
{"tool_id": web_search_tool[0]},
|
||||
)
|
||||
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO persona__tool (persona_id, tool_id)
|
||||
VALUES (0, :tool_id)
|
||||
ON CONFLICT DO NOTHING
|
||||
# Step 4: Migrate existing chat sessions from all builtin assistants to unified assistant
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
),
|
||||
{"tool_id": image_gen_tool[0]},
|
||||
UPDATE chat_session
|
||||
SET persona_id = 0
|
||||
WHERE persona_id IN (
|
||||
SELECT id FROM persona WHERE builtin_persona = true AND id != 0
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
if web_search_tool:
|
||||
# Step 5: Migrate user preferences - remove references to all builtin assistants
|
||||
# First, get all builtin assistant IDs (except 0)
|
||||
builtin_assistants_result = conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT id FROM persona
|
||||
WHERE builtin_persona = true AND id != 0
|
||||
"""
|
||||
)
|
||||
).fetchall()
|
||||
builtin_assistant_ids = [row[0] for row in builtin_assistants_result]
|
||||
|
||||
# Get all users with preferences
|
||||
users_result = conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT id, chosen_assistants, visible_assistants,
|
||||
hidden_assistants, pinned_assistants
|
||||
FROM "user"
|
||||
"""
|
||||
)
|
||||
).fetchall()
|
||||
|
||||
for user_row in users_result:
|
||||
user = UserRow(*user_row)
|
||||
user_id: UUID = user.id
|
||||
updates: dict[str, Any] = {}
|
||||
|
||||
# Remove all builtin assistants from chosen_assistants
|
||||
if user.chosen_assistants:
|
||||
new_chosen: list[int] = [
|
||||
assistant_id
|
||||
for assistant_id in user.chosen_assistants
|
||||
if assistant_id not in builtin_assistant_ids
|
||||
]
|
||||
if new_chosen != user.chosen_assistants:
|
||||
updates["chosen_assistants"] = json.dumps(new_chosen)
|
||||
|
||||
# Remove all builtin assistants from visible_assistants
|
||||
if user.visible_assistants:
|
||||
new_visible: list[int] = [
|
||||
assistant_id
|
||||
for assistant_id in user.visible_assistants
|
||||
if assistant_id not in builtin_assistant_ids
|
||||
]
|
||||
if new_visible != user.visible_assistants:
|
||||
updates["visible_assistants"] = json.dumps(new_visible)
|
||||
|
||||
# Add all builtin assistants to hidden_assistants
|
||||
if user.hidden_assistants:
|
||||
new_hidden: list[int] = list(user.hidden_assistants)
|
||||
for old_id in builtin_assistant_ids:
|
||||
if old_id not in new_hidden:
|
||||
new_hidden.append(old_id)
|
||||
if new_hidden != user.hidden_assistants:
|
||||
updates["hidden_assistants"] = json.dumps(new_hidden)
|
||||
else:
|
||||
updates["hidden_assistants"] = json.dumps(builtin_assistant_ids)
|
||||
|
||||
# Remove all builtin assistants from pinned_assistants
|
||||
if user.pinned_assistants:
|
||||
new_pinned: list[int] = [
|
||||
assistant_id
|
||||
for assistant_id in user.pinned_assistants
|
||||
if assistant_id not in builtin_assistant_ids
|
||||
]
|
||||
if new_pinned != user.pinned_assistants:
|
||||
updates["pinned_assistants"] = json.dumps(new_pinned)
|
||||
|
||||
# Apply updates if any
|
||||
if updates:
|
||||
set_clause = ", ".join([f"{k} = :{k}" for k in updates.keys()])
|
||||
updates["user_id"] = str(user_id) # Convert UUID to string for SQL
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO persona__tool (persona_id, tool_id)
|
||||
VALUES (0, :tool_id)
|
||||
ON CONFLICT DO NOTHING
|
||||
"""
|
||||
),
|
||||
{"tool_id": web_search_tool[0]},
|
||||
sa.text(f'UPDATE "user" SET {set_clause} WHERE id = :user_id'),
|
||||
updates,
|
||||
)
|
||||
|
||||
# Step 4: Migrate existing chat sessions from all builtin assistants to unified assistant
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE chat_session
|
||||
SET persona_id = 0
|
||||
WHERE persona_id IN (
|
||||
SELECT id FROM persona WHERE builtin_persona = true AND id != 0
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
# Step 5: Migrate user preferences - remove references to all builtin assistants
|
||||
# First, get all builtin assistant IDs (except 0)
|
||||
builtin_assistants_result = conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT id FROM persona
|
||||
WHERE builtin_persona = true AND id != 0
|
||||
"""
|
||||
)
|
||||
).fetchall()
|
||||
builtin_assistant_ids = [row[0] for row in builtin_assistants_result]
|
||||
|
||||
# Get all users with preferences
|
||||
users_result = conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
SELECT id, chosen_assistants, visible_assistants,
|
||||
hidden_assistants, pinned_assistants
|
||||
FROM "user"
|
||||
"""
|
||||
)
|
||||
).fetchall()
|
||||
|
||||
for user_row in users_result:
|
||||
user = UserRow(*user_row)
|
||||
user_id: UUID = user.id
|
||||
updates: dict[str, Any] = {}
|
||||
|
||||
# Remove all builtin assistants from chosen_assistants
|
||||
if user.chosen_assistants:
|
||||
new_chosen: list[int] = [
|
||||
assistant_id
|
||||
for assistant_id in user.chosen_assistants
|
||||
if assistant_id not in builtin_assistant_ids
|
||||
]
|
||||
if new_chosen != user.chosen_assistants:
|
||||
updates["chosen_assistants"] = json.dumps(new_chosen)
|
||||
|
||||
# Remove all builtin assistants from visible_assistants
|
||||
if user.visible_assistants:
|
||||
new_visible: list[int] = [
|
||||
assistant_id
|
||||
for assistant_id in user.visible_assistants
|
||||
if assistant_id not in builtin_assistant_ids
|
||||
]
|
||||
if new_visible != user.visible_assistants:
|
||||
updates["visible_assistants"] = json.dumps(new_visible)
|
||||
|
||||
# Add all builtin assistants to hidden_assistants
|
||||
if user.hidden_assistants:
|
||||
new_hidden: list[int] = list(user.hidden_assistants)
|
||||
for old_id in builtin_assistant_ids:
|
||||
if old_id not in new_hidden:
|
||||
new_hidden.append(old_id)
|
||||
if new_hidden != user.hidden_assistants:
|
||||
updates["hidden_assistants"] = json.dumps(new_hidden)
|
||||
else:
|
||||
updates["hidden_assistants"] = json.dumps(builtin_assistant_ids)
|
||||
|
||||
# Remove all builtin assistants from pinned_assistants
|
||||
if user.pinned_assistants:
|
||||
new_pinned: list[int] = [
|
||||
assistant_id
|
||||
for assistant_id in user.pinned_assistants
|
||||
if assistant_id not in builtin_assistant_ids
|
||||
]
|
||||
if new_pinned != user.pinned_assistants:
|
||||
updates["pinned_assistants"] = json.dumps(new_pinned)
|
||||
|
||||
# Apply updates if any
|
||||
if updates:
|
||||
set_clause = ", ".join([f"{k} = :{k}" for k in updates.keys()])
|
||||
updates["user_id"] = str(user_id) # Convert UUID to string for SQL
|
||||
conn.execute(
|
||||
sa.text(f'UPDATE "user" SET {set_clause} WHERE id = :user_id'),
|
||||
updates,
|
||||
)
|
||||
|
||||
# Commit transaction
|
||||
conn.execute(sa.text("COMMIT"))
|
||||
|
||||
except Exception as e:
|
||||
# Rollback on error
|
||||
conn.execute(sa.text("ROLLBACK"))
|
||||
raise e
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# Start transaction
|
||||
conn.execute(sa.text("BEGIN"))
|
||||
|
||||
try:
|
||||
# Only restore General (ID -1) and Art (ID -3) assistants
|
||||
# Step 1: Keep Search assistant (ID 0) as default but restore original state
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE persona
|
||||
SET is_default_persona = true,
|
||||
is_visible = true,
|
||||
deleted = false
|
||||
WHERE id = 0
|
||||
# Only restore General (ID -1) and Art (ID -3) assistants
|
||||
# Step 1: Keep Search assistant (ID 0) as default but restore original state
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
)
|
||||
UPDATE persona
|
||||
SET is_default_persona = true,
|
||||
is_visible = true,
|
||||
deleted = false
|
||||
WHERE id = 0
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
# Step 2: Restore General assistant (ID -1)
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE persona
|
||||
SET deleted = false,
|
||||
is_visible = true,
|
||||
is_default_persona = true
|
||||
WHERE id = :general_assistant_id
|
||||
# Step 2: Restore General assistant (ID -1)
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
),
|
||||
{"general_assistant_id": GENERAL_ASSISTANT_ID},
|
||||
)
|
||||
UPDATE persona
|
||||
SET deleted = false,
|
||||
is_visible = true,
|
||||
is_default_persona = true
|
||||
WHERE id = :general_assistant_id
|
||||
"""
|
||||
),
|
||||
{"general_assistant_id": GENERAL_ASSISTANT_ID},
|
||||
)
|
||||
|
||||
# Step 3: Restore Art assistant (ID -3)
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE persona
|
||||
SET deleted = false,
|
||||
is_visible = true,
|
||||
is_default_persona = true
|
||||
WHERE id = :art_assistant_id
|
||||
# Step 3: Restore Art assistant (ID -3)
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
),
|
||||
{"art_assistant_id": ART_ASSISTANT_ID},
|
||||
)
|
||||
UPDATE persona
|
||||
SET deleted = false,
|
||||
is_visible = true,
|
||||
is_default_persona = true
|
||||
WHERE id = :art_assistant_id
|
||||
"""
|
||||
),
|
||||
{"art_assistant_id": ART_ASSISTANT_ID},
|
||||
)
|
||||
|
||||
# Note: We don't restore the original tool associations, names, or descriptions
|
||||
# as those would require more complex logic to determine original state.
|
||||
# We also cannot restore original chat session persona_ids as we don't
|
||||
# have the original mappings.
|
||||
# Other builtin assistants remain deleted as per the requirement.
|
||||
|
||||
# Commit transaction
|
||||
conn.execute(sa.text("COMMIT"))
|
||||
|
||||
except Exception as e:
|
||||
# Rollback on error
|
||||
conn.execute(sa.text("ROLLBACK"))
|
||||
raise e
|
||||
# Note: We don't restore the original tool associations, names, or descriptions
|
||||
# as those would require more complex logic to determine original state.
|
||||
# We also cannot restore original chat session persona_ids as we don't
|
||||
# have the original mappings.
|
||||
# Other builtin assistants remain deleted as per the requirement.
|
||||
|
||||
@@ -24,6 +24,9 @@ def upgrade() -> None:
|
||||
# in unique constraints, but we want NULL == NULL for deduplication).
|
||||
# The '{}' represents an empty JSONB object as the NULL replacement.
|
||||
|
||||
# Clean up legacy notifications first
|
||||
op.execute("DELETE FROM notification WHERE title = 'New Notification'")
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ix_notification_user_type_data
|
||||
@@ -40,9 +43,6 @@ def upgrade() -> None:
|
||||
"""
|
||||
)
|
||||
|
||||
# Clean up legacy 'reindex' notifications that are no longer needed
|
||||
op.execute("DELETE FROM notification WHERE title = 'New Notification'")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS ix_notification_user_type_data")
|
||||
|
||||
@@ -42,20 +42,13 @@ TOOL_DESCRIPTIONS = {
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
conn.execute(sa.text("BEGIN"))
|
||||
|
||||
try:
|
||||
for tool_id, description in TOOL_DESCRIPTIONS.items():
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"UPDATE tool SET description = :description WHERE in_code_tool_id = :tool_id"
|
||||
),
|
||||
{"description": description, "tool_id": tool_id},
|
||||
)
|
||||
conn.execute(sa.text("COMMIT"))
|
||||
except Exception as e:
|
||||
conn.execute(sa.text("ROLLBACK"))
|
||||
raise e
|
||||
for tool_id, description in TOOL_DESCRIPTIONS.items():
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"UPDATE tool SET description = :description WHERE in_code_tool_id = :tool_id"
|
||||
),
|
||||
{"description": description, "tool_id": tool_id},
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
||||
@@ -70,80 +70,66 @@ BUILT_IN_TOOLS = [
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# Start transaction
|
||||
conn.execute(sa.text("BEGIN"))
|
||||
# Get existing tools to check what already exists
|
||||
existing_tools = conn.execute(
|
||||
sa.text("SELECT in_code_tool_id FROM tool WHERE in_code_tool_id IS NOT NULL")
|
||||
).fetchall()
|
||||
existing_tool_ids = {row[0] for row in existing_tools}
|
||||
|
||||
try:
|
||||
# Get existing tools to check what already exists
|
||||
existing_tools = conn.execute(
|
||||
sa.text(
|
||||
"SELECT in_code_tool_id FROM tool WHERE in_code_tool_id IS NOT NULL"
|
||||
# Insert or update built-in tools
|
||||
for tool in BUILT_IN_TOOLS:
|
||||
in_code_id = tool["in_code_tool_id"]
|
||||
|
||||
# Handle historical rename: InternetSearchTool -> WebSearchTool
|
||||
if (
|
||||
in_code_id == "WebSearchTool"
|
||||
and "WebSearchTool" not in existing_tool_ids
|
||||
and "InternetSearchTool" in existing_tool_ids
|
||||
):
|
||||
# Rename the existing InternetSearchTool row in place and update fields
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE tool
|
||||
SET name = :name,
|
||||
display_name = :display_name,
|
||||
description = :description,
|
||||
in_code_tool_id = :in_code_tool_id
|
||||
WHERE in_code_tool_id = 'InternetSearchTool'
|
||||
"""
|
||||
),
|
||||
tool,
|
||||
)
|
||||
).fetchall()
|
||||
existing_tool_ids = {row[0] for row in existing_tools}
|
||||
# Keep the local view of existing ids in sync to avoid duplicate insert
|
||||
existing_tool_ids.discard("InternetSearchTool")
|
||||
existing_tool_ids.add("WebSearchTool")
|
||||
continue
|
||||
|
||||
# Insert or update built-in tools
|
||||
for tool in BUILT_IN_TOOLS:
|
||||
in_code_id = tool["in_code_tool_id"]
|
||||
|
||||
# Handle historical rename: InternetSearchTool -> WebSearchTool
|
||||
if (
|
||||
in_code_id == "WebSearchTool"
|
||||
and "WebSearchTool" not in existing_tool_ids
|
||||
and "InternetSearchTool" in existing_tool_ids
|
||||
):
|
||||
# Rename the existing InternetSearchTool row in place and update fields
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE tool
|
||||
SET name = :name,
|
||||
display_name = :display_name,
|
||||
description = :description,
|
||||
in_code_tool_id = :in_code_tool_id
|
||||
WHERE in_code_tool_id = 'InternetSearchTool'
|
||||
"""
|
||||
),
|
||||
tool,
|
||||
)
|
||||
# Keep the local view of existing ids in sync to avoid duplicate insert
|
||||
existing_tool_ids.discard("InternetSearchTool")
|
||||
existing_tool_ids.add("WebSearchTool")
|
||||
continue
|
||||
|
||||
if in_code_id in existing_tool_ids:
|
||||
# Update existing tool
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE tool
|
||||
SET name = :name,
|
||||
display_name = :display_name,
|
||||
description = :description
|
||||
WHERE in_code_tool_id = :in_code_tool_id
|
||||
"""
|
||||
),
|
||||
tool,
|
||||
)
|
||||
else:
|
||||
# Insert new tool
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO tool (name, display_name, description, in_code_tool_id)
|
||||
VALUES (:name, :display_name, :description, :in_code_tool_id)
|
||||
"""
|
||||
),
|
||||
tool,
|
||||
)
|
||||
|
||||
# Commit transaction
|
||||
conn.execute(sa.text("COMMIT"))
|
||||
|
||||
except Exception as e:
|
||||
# Rollback on error
|
||||
conn.execute(sa.text("ROLLBACK"))
|
||||
raise e
|
||||
if in_code_id in existing_tool_ids:
|
||||
# Update existing tool
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE tool
|
||||
SET name = :name,
|
||||
display_name = :display_name,
|
||||
description = :description
|
||||
WHERE in_code_tool_id = :in_code_tool_id
|
||||
"""
|
||||
),
|
||||
tool,
|
||||
)
|
||||
else:
|
||||
# Insert new tool
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO tool (name, display_name, description, in_code_tool_id)
|
||||
VALUES (:name, :display_name, :description, :in_code_tool_id)
|
||||
"""
|
||||
),
|
||||
tool,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
||||
@@ -109,7 +109,6 @@ CHECK_TTL_MANAGEMENT_TASK_FREQUENCY_IN_HOURS = float(
|
||||
|
||||
|
||||
STRIPE_SECRET_KEY = os.environ.get("STRIPE_SECRET_KEY")
|
||||
STRIPE_PRICE_ID = os.environ.get("STRIPE_PRICE")
|
||||
|
||||
# JWT Public Key URL
|
||||
JWT_PUBLIC_KEY_URL: str | None = os.getenv("JWT_PUBLIC_KEY_URL", None)
|
||||
|
||||
@@ -3,30 +3,42 @@ from uuid import UUID
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from onyx.configs.constants import NotificationType
|
||||
from onyx.db.models import Persona
|
||||
from onyx.db.models import Persona__User
|
||||
from onyx.db.models import Persona__UserGroup
|
||||
from onyx.db.notification import create_notification
|
||||
from onyx.server.features.persona.models import PersonaSharedNotificationData
|
||||
|
||||
|
||||
def make_persona_private(
|
||||
def update_persona_access(
|
||||
persona_id: int,
|
||||
creator_user_id: UUID | None,
|
||||
user_ids: list[UUID] | None,
|
||||
group_ids: list[int] | None,
|
||||
db_session: Session,
|
||||
is_public: bool | None = None,
|
||||
user_ids: list[UUID] | None = None,
|
||||
group_ids: list[int] | None = None,
|
||||
) -> None:
|
||||
"""NOTE(rkuo): This function batches all updates into a single commit. If we don't
|
||||
dedupe the inputs, the commit will exception."""
|
||||
"""Updates the access settings for a persona including public status, user shares,
|
||||
and group shares.
|
||||
|
||||
db_session.query(Persona__User).filter(
|
||||
Persona__User.persona_id == persona_id
|
||||
).delete(synchronize_session="fetch")
|
||||
db_session.query(Persona__UserGroup).filter(
|
||||
Persona__UserGroup.persona_id == persona_id
|
||||
).delete(synchronize_session="fetch")
|
||||
NOTE: This function batches all updates. If we don't dedupe the inputs,
|
||||
the commit will exception.
|
||||
|
||||
NOTE: Callers are responsible for committing."""
|
||||
|
||||
if is_public is not None:
|
||||
persona = db_session.query(Persona).filter(Persona.id == persona_id).first()
|
||||
if persona:
|
||||
persona.is_public = is_public
|
||||
|
||||
# NOTE: For user-ids and group-ids, `None` means "leave unchanged", `[]` means "clear all shares",
|
||||
# and a non-empty list means "replace with these shares".
|
||||
|
||||
if user_ids is not None:
|
||||
db_session.query(Persona__User).filter(
|
||||
Persona__User.persona_id == persona_id
|
||||
).delete(synchronize_session="fetch")
|
||||
|
||||
if user_ids:
|
||||
user_ids_set = set(user_ids)
|
||||
for user_id in user_ids_set:
|
||||
db_session.add(Persona__User(persona_id=persona_id, user_id=user_id))
|
||||
@@ -41,11 +53,13 @@ def make_persona_private(
|
||||
).model_dump(),
|
||||
)
|
||||
|
||||
if group_ids:
|
||||
if group_ids is not None:
|
||||
db_session.query(Persona__UserGroup).filter(
|
||||
Persona__UserGroup.persona_id == persona_id
|
||||
).delete(synchronize_session="fetch")
|
||||
|
||||
group_ids_set = set(group_ids)
|
||||
for group_id in group_ids_set:
|
||||
db_session.add(
|
||||
Persona__UserGroup(persona_id=persona_id, user_group_id=group_id)
|
||||
)
|
||||
|
||||
db_session.commit()
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
from typing import cast
|
||||
from typing import Literal
|
||||
|
||||
import requests
|
||||
import stripe
|
||||
|
||||
from ee.onyx.configs.app_configs import STRIPE_PRICE_ID
|
||||
from ee.onyx.configs.app_configs import STRIPE_SECRET_KEY
|
||||
from ee.onyx.server.tenants.access import generate_data_plane_token
|
||||
from ee.onyx.server.tenants.models import BillingInformation
|
||||
@@ -16,15 +16,21 @@ stripe.api_key = STRIPE_SECRET_KEY
|
||||
logger = setup_logger()
|
||||
|
||||
|
||||
def fetch_stripe_checkout_session(tenant_id: str) -> str:
|
||||
def fetch_stripe_checkout_session(
|
||||
tenant_id: str,
|
||||
billing_period: Literal["monthly", "annual"] = "monthly",
|
||||
) -> str:
|
||||
token = generate_data_plane_token()
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
url = f"{CONTROL_PLANE_API_BASE_URL}/create-checkout-session"
|
||||
params = {"tenant_id": tenant_id}
|
||||
response = requests.post(url, headers=headers, params=params)
|
||||
payload = {
|
||||
"tenant_id": tenant_id,
|
||||
"billing_period": billing_period,
|
||||
}
|
||||
response = requests.post(url, headers=headers, json=payload)
|
||||
response.raise_for_status()
|
||||
return response.json()["sessionId"]
|
||||
|
||||
@@ -72,22 +78,24 @@ def fetch_billing_information(
|
||||
|
||||
def register_tenant_users(tenant_id: str, number_of_users: int) -> stripe.Subscription:
|
||||
"""
|
||||
Send a request to the control service to register the number of users for a tenant.
|
||||
Update the number of seats for a tenant's subscription.
|
||||
Preserves the existing price (monthly, annual, or grandfathered).
|
||||
"""
|
||||
|
||||
if not STRIPE_PRICE_ID:
|
||||
raise Exception("STRIPE_PRICE_ID is not set")
|
||||
|
||||
response = fetch_tenant_stripe_information(tenant_id)
|
||||
stripe_subscription_id = cast(str, response.get("stripe_subscription_id"))
|
||||
|
||||
subscription = stripe.Subscription.retrieve(stripe_subscription_id)
|
||||
subscription_item = subscription["items"]["data"][0]
|
||||
|
||||
# Use existing price to preserve the customer's current plan
|
||||
current_price_id = subscription_item.price.id
|
||||
|
||||
updated_subscription = stripe.Subscription.modify(
|
||||
stripe_subscription_id,
|
||||
items=[
|
||||
{
|
||||
"id": subscription["items"]["data"][0].id,
|
||||
"price": STRIPE_PRICE_ID,
|
||||
"id": subscription_item.id,
|
||||
"price": current_price_id,
|
||||
"quantity": number_of_users,
|
||||
}
|
||||
],
|
||||
|
||||
@@ -10,6 +10,7 @@ from ee.onyx.server.tenants.billing import fetch_billing_information
|
||||
from ee.onyx.server.tenants.billing import fetch_stripe_checkout_session
|
||||
from ee.onyx.server.tenants.billing import fetch_tenant_stripe_information
|
||||
from ee.onyx.server.tenants.models import BillingInformation
|
||||
from ee.onyx.server.tenants.models import CreateSubscriptionSessionRequest
|
||||
from ee.onyx.server.tenants.models import ProductGatingFullSyncRequest
|
||||
from ee.onyx.server.tenants.models import ProductGatingRequest
|
||||
from ee.onyx.server.tenants.models import ProductGatingResponse
|
||||
@@ -104,15 +105,18 @@ async def create_customer_portal_session(
|
||||
|
||||
@router.post("/create-subscription-session")
|
||||
async def create_subscription_session(
|
||||
request: CreateSubscriptionSessionRequest | None = None,
|
||||
_: User = Depends(current_admin_user),
|
||||
) -> SubscriptionSessionResponse:
|
||||
try:
|
||||
tenant_id = CURRENT_TENANT_ID_CONTEXTVAR.get()
|
||||
if not tenant_id:
|
||||
raise HTTPException(status_code=400, detail="Tenant ID not found")
|
||||
session_id = fetch_stripe_checkout_session(tenant_id)
|
||||
|
||||
billing_period = request.billing_period if request else "monthly"
|
||||
session_id = fetch_stripe_checkout_session(tenant_id, billing_period)
|
||||
return SubscriptionSessionResponse(sessionId=session_id)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("Failed to create resubscription session")
|
||||
logger.exception("Failed to create subscription session")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -73,6 +74,12 @@ class SubscriptionSessionResponse(BaseModel):
|
||||
sessionId: str
|
||||
|
||||
|
||||
class CreateSubscriptionSessionRequest(BaseModel):
|
||||
"""Request to create a subscription checkout session."""
|
||||
|
||||
billing_period: Literal["monthly", "annual"] = "monthly"
|
||||
|
||||
|
||||
class TenantByDomainResponse(BaseModel):
|
||||
tenant_id: str
|
||||
number_of_users: int
|
||||
|
||||
@@ -566,6 +566,23 @@ def extract_content_words_from_recency_query(
|
||||
return content_words_filtered[:MAX_CONTENT_WORDS]
|
||||
|
||||
|
||||
def _is_valid_keyword_query(line: str) -> bool:
|
||||
"""Check if a line looks like a valid keyword query vs explanatory text.
|
||||
|
||||
Returns False for lines that appear to be LLM explanations rather than keywords.
|
||||
"""
|
||||
# Reject lines that start with parentheses (explanatory notes)
|
||||
if line.startswith("("):
|
||||
return False
|
||||
|
||||
# Reject lines that are too long (likely sentences, not keywords)
|
||||
# Keywords should be short - reject if > 50 chars or > 6 words
|
||||
if len(line) > 50 or len(line.split()) > 6:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def expand_query_with_llm(query_text: str, llm: LLM) -> list[str]:
|
||||
"""Use LLM to expand query into multiple search variations.
|
||||
|
||||
@@ -586,10 +603,18 @@ def expand_query_with_llm(query_text: str, llm: LLM) -> list[str]:
|
||||
response_clean = _parse_llm_code_block_response(response)
|
||||
|
||||
# Split into lines and filter out empty lines
|
||||
rephrased_queries = [
|
||||
raw_queries = [
|
||||
line.strip() for line in response_clean.split("\n") if line.strip()
|
||||
]
|
||||
|
||||
# Filter out lines that look like explanatory text rather than keywords
|
||||
rephrased_queries = [q for q in raw_queries if _is_valid_keyword_query(q)]
|
||||
|
||||
# Log if we filtered out garbage
|
||||
if len(raw_queries) != len(rephrased_queries):
|
||||
filtered_out = set(raw_queries) - set(rephrased_queries)
|
||||
logger.warning(f"Filtered out non-keyword LLM responses: {filtered_out}")
|
||||
|
||||
# If no queries generated, use empty query
|
||||
if not rephrased_queries:
|
||||
logger.debug("No content keywords extracted from query expansion")
|
||||
|
||||
@@ -444,6 +444,8 @@ def upsert_documents(
|
||||
logger.info("No documents to upsert. Skipping.")
|
||||
return
|
||||
|
||||
includes_permissions = any(doc.external_access for doc in seen_documents.values())
|
||||
|
||||
insert_stmt = insert(DbDocument).values(
|
||||
[
|
||||
model_to_dict(
|
||||
@@ -479,21 +481,38 @@ def upsert_documents(
|
||||
]
|
||||
)
|
||||
|
||||
update_set = {
|
||||
"from_ingestion_api": insert_stmt.excluded.from_ingestion_api,
|
||||
"boost": insert_stmt.excluded.boost,
|
||||
"hidden": insert_stmt.excluded.hidden,
|
||||
"semantic_id": insert_stmt.excluded.semantic_id,
|
||||
"link": insert_stmt.excluded.link,
|
||||
"primary_owners": insert_stmt.excluded.primary_owners,
|
||||
"secondary_owners": insert_stmt.excluded.secondary_owners,
|
||||
"doc_metadata": insert_stmt.excluded.doc_metadata,
|
||||
}
|
||||
if includes_permissions:
|
||||
# Use COALESCE to preserve existing permissions when new values are NULL.
|
||||
# This prevents subsequent indexing runs (which don't fetch permissions)
|
||||
# from overwriting permissions set by permission sync jobs.
|
||||
update_set.update(
|
||||
{
|
||||
"external_user_emails": func.coalesce(
|
||||
insert_stmt.excluded.external_user_emails,
|
||||
DbDocument.external_user_emails,
|
||||
),
|
||||
"external_user_group_ids": func.coalesce(
|
||||
insert_stmt.excluded.external_user_group_ids,
|
||||
DbDocument.external_user_group_ids,
|
||||
),
|
||||
"is_public": func.coalesce(
|
||||
insert_stmt.excluded.is_public,
|
||||
DbDocument.is_public,
|
||||
),
|
||||
}
|
||||
)
|
||||
on_conflict_stmt = insert_stmt.on_conflict_do_update(
|
||||
index_elements=["id"], # Conflict target
|
||||
set_={
|
||||
"from_ingestion_api": insert_stmt.excluded.from_ingestion_api,
|
||||
"boost": insert_stmt.excluded.boost,
|
||||
"hidden": insert_stmt.excluded.hidden,
|
||||
"semantic_id": insert_stmt.excluded.semantic_id,
|
||||
"link": insert_stmt.excluded.link,
|
||||
"primary_owners": insert_stmt.excluded.primary_owners,
|
||||
"secondary_owners": insert_stmt.excluded.secondary_owners,
|
||||
"external_user_emails": insert_stmt.excluded.external_user_emails,
|
||||
"external_user_group_ids": insert_stmt.excluded.external_user_group_ids,
|
||||
"is_public": insert_stmt.excluded.is_public,
|
||||
"doc_metadata": insert_stmt.excluded.doc_metadata,
|
||||
},
|
||||
index_elements=["id"], set_=update_set # Conflict target
|
||||
)
|
||||
db_session.execute(on_conflict_stmt)
|
||||
db_session.commit()
|
||||
|
||||
@@ -187,13 +187,25 @@ def _get_persona_by_name(
|
||||
return result
|
||||
|
||||
|
||||
def make_persona_private(
|
||||
def update_persona_access(
|
||||
persona_id: int,
|
||||
creator_user_id: UUID | None,
|
||||
user_ids: list[UUID] | None,
|
||||
group_ids: list[int] | None,
|
||||
db_session: Session,
|
||||
is_public: bool | None = None,
|
||||
user_ids: list[UUID] | None = None,
|
||||
group_ids: list[int] | None = None,
|
||||
) -> None:
|
||||
"""Updates the access settings for a persona including public status and user shares.
|
||||
|
||||
NOTE: Callers are responsible for committing."""
|
||||
|
||||
if is_public is not None:
|
||||
persona = db_session.query(Persona).filter(Persona.id == persona_id).first()
|
||||
if persona:
|
||||
persona.is_public = is_public
|
||||
|
||||
# NOTE: For user-ids and group-ids, `None` means "leave unchanged", `[]` means "clear all shares",
|
||||
# and a non-empty list means "replace with these shares".
|
||||
if user_ids is not None:
|
||||
db_session.query(Persona__User).filter(
|
||||
Persona__User.persona_id == persona_id
|
||||
@@ -212,11 +224,15 @@ def make_persona_private(
|
||||
).model_dump(),
|
||||
)
|
||||
|
||||
db_session.commit()
|
||||
# MIT doesn't support group-based sharing, so we allow clearing (no-op since
|
||||
# there shouldn't be any) but raise an error if trying to add actual groups.
|
||||
if group_ids is not None:
|
||||
db_session.query(Persona__UserGroup).filter(
|
||||
Persona__UserGroup.persona_id == persona_id
|
||||
).delete(synchronize_session="fetch")
|
||||
|
||||
# May cause error if someone switches down to MIT from EE
|
||||
if group_ids:
|
||||
raise NotImplementedError("Onyx MIT does not support private Personas")
|
||||
if group_ids:
|
||||
raise NotImplementedError("Onyx MIT does not support group-based sharing")
|
||||
|
||||
|
||||
def create_update_persona(
|
||||
@@ -282,20 +298,21 @@ def create_update_persona(
|
||||
llm_filter_extraction=create_persona_request.llm_filter_extraction,
|
||||
is_default_persona=create_persona_request.is_default_persona,
|
||||
user_file_ids=converted_user_file_ids,
|
||||
commit=False,
|
||||
)
|
||||
|
||||
versioned_make_persona_private = fetch_versioned_implementation(
|
||||
"onyx.db.persona", "make_persona_private"
|
||||
versioned_update_persona_access = fetch_versioned_implementation(
|
||||
"onyx.db.persona", "update_persona_access"
|
||||
)
|
||||
|
||||
# Privatize Persona
|
||||
versioned_make_persona_private(
|
||||
versioned_update_persona_access(
|
||||
persona_id=persona.id,
|
||||
creator_user_id=user.id if user else None,
|
||||
db_session=db_session,
|
||||
user_ids=create_persona_request.users,
|
||||
group_ids=create_persona_request.groups,
|
||||
db_session=db_session,
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
except ValueError as e:
|
||||
logger.exception("Failed to create persona")
|
||||
@@ -304,11 +321,13 @@ def create_update_persona(
|
||||
return FullPersonaSnapshot.from_model(persona)
|
||||
|
||||
|
||||
def update_persona_shared_users(
|
||||
def update_persona_shared(
|
||||
persona_id: int,
|
||||
user_ids: list[UUID],
|
||||
user: User | None,
|
||||
db_session: Session,
|
||||
user_ids: list[UUID] | None = None,
|
||||
group_ids: list[int] | None = None,
|
||||
is_public: bool | None = None,
|
||||
) -> None:
|
||||
"""Simplified version of `create_update_persona` which only touches the
|
||||
accessibility rather than any of the logic (e.g. prompt, connected data sources,
|
||||
@@ -317,22 +336,25 @@ def update_persona_shared_users(
|
||||
db_session=db_session, persona_id=persona_id, user=user, get_editable=True
|
||||
)
|
||||
|
||||
if persona.is_public:
|
||||
raise HTTPException(status_code=400, detail="Cannot share public persona")
|
||||
if user and user.role != UserRole.ADMIN and persona.user_id != user.id:
|
||||
raise HTTPException(
|
||||
status_code=403, detail="You don't have permission to modify this persona"
|
||||
)
|
||||
|
||||
versioned_make_persona_private = fetch_versioned_implementation(
|
||||
"onyx.db.persona", "make_persona_private"
|
||||
versioned_update_persona_access = fetch_versioned_implementation(
|
||||
"onyx.db.persona", "update_persona_access"
|
||||
)
|
||||
|
||||
# Privatize Persona
|
||||
versioned_make_persona_private(
|
||||
versioned_update_persona_access(
|
||||
persona_id=persona_id,
|
||||
creator_user_id=user.id if user else None,
|
||||
user_ids=user_ids,
|
||||
group_ids=None,
|
||||
db_session=db_session,
|
||||
is_public=is_public,
|
||||
user_ids=user_ids,
|
||||
group_ids=group_ids,
|
||||
)
|
||||
|
||||
db_session.commit()
|
||||
|
||||
|
||||
def update_persona_public_status(
|
||||
persona_id: int,
|
||||
|
||||
@@ -63,7 +63,7 @@ def process_with_prompt_cache(
|
||||
return suffix, None
|
||||
|
||||
# Get provider adapter
|
||||
provider_adapter = get_provider_adapter(llm_config.model_provider)
|
||||
provider_adapter = get_provider_adapter(llm_config)
|
||||
|
||||
# If provider doesn't support caching, combine and return unchanged
|
||||
if not provider_adapter.supports_caching():
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
"""Factory for creating provider-specific prompt cache adapters."""
|
||||
|
||||
from onyx.llm.constants import LlmProviderNames
|
||||
from onyx.llm.interfaces import LLMConfig
|
||||
from onyx.llm.prompt_cache.providers.anthropic import AnthropicPromptCacheProvider
|
||||
from onyx.llm.prompt_cache.providers.base import PromptCacheProvider
|
||||
from onyx.llm.prompt_cache.providers.noop import NoOpPromptCacheProvider
|
||||
from onyx.llm.prompt_cache.providers.openai import OpenAIPromptCacheProvider
|
||||
from onyx.llm.prompt_cache.providers.vertex import VertexAIPromptCacheProvider
|
||||
|
||||
ANTHROPIC_BEDROCK_TAG = "anthropic."
|
||||
|
||||
def get_provider_adapter(provider: str) -> PromptCacheProvider:
|
||||
|
||||
def get_provider_adapter(llm_config: LLMConfig) -> PromptCacheProvider:
|
||||
"""Get the appropriate prompt cache provider adapter for a given provider.
|
||||
|
||||
Args:
|
||||
@@ -17,11 +20,14 @@ def get_provider_adapter(provider: str) -> PromptCacheProvider:
|
||||
Returns:
|
||||
PromptCacheProvider instance for the given provider
|
||||
"""
|
||||
if provider == LlmProviderNames.OPENAI:
|
||||
if llm_config.model_provider == LlmProviderNames.OPENAI:
|
||||
return OpenAIPromptCacheProvider()
|
||||
elif provider in [LlmProviderNames.ANTHROPIC, LlmProviderNames.BEDROCK]:
|
||||
elif llm_config.model_provider == LlmProviderNames.ANTHROPIC or (
|
||||
llm_config.model_provider == LlmProviderNames.BEDROCK
|
||||
and ANTHROPIC_BEDROCK_TAG in llm_config.model_name
|
||||
):
|
||||
return AnthropicPromptCacheProvider()
|
||||
elif provider == LlmProviderNames.VERTEX_AI:
|
||||
elif llm_config.model_provider == LlmProviderNames.VERTEX_AI:
|
||||
return VertexAIPromptCacheProvider()
|
||||
else:
|
||||
# Default to no-op for providers without caching support
|
||||
|
||||
@@ -1,30 +1,39 @@
|
||||
from onyx.configs.app_configs import MAX_SLACK_QUERY_EXPANSIONS
|
||||
|
||||
SLACK_QUERY_EXPANSION_PROMPT = f"""
|
||||
Rewrite the user's query and, if helpful, split it into at most {MAX_SLACK_QUERY_EXPANSIONS} \
|
||||
keyword-only queries, so that Slack's keyword search yields the best matches.
|
||||
Rewrite the user's query into at most {MAX_SLACK_QUERY_EXPANSIONS} keyword-only queries for Slack's keyword search.
|
||||
|
||||
Keep in mind the Slack's search behavior:
|
||||
- Pure keyword AND search (no semantics).
|
||||
- Word order matters.
|
||||
- More words = fewer matches, so keep each query concise.
|
||||
- IMPORTANT: Prefer simple 1-2 word queries over longer multi-word queries.
|
||||
Slack search behavior:
|
||||
- Pure keyword AND search (no semantics)
|
||||
- More words = fewer matches, so keep queries concise (1-3 words)
|
||||
|
||||
Critical: Extract ONLY keywords that would actually appear in Slack message content.
|
||||
ALWAYS include:
|
||||
- Person names (e.g., "Sarah Chen", "Mike Johnson") - people search for messages from/about specific people
|
||||
- Project/product names, technical terms, proper nouns
|
||||
- Actual content words: "performance", "bug", "deployment", "API", "error"
|
||||
|
||||
DO NOT include:
|
||||
- Meta-words: "topics", "conversations", "discussed", "summary", "messages", "big", "main", "talking"
|
||||
- Temporal: "today", "yesterday", "week", "month", "recent", "past", "last"
|
||||
- Channels/Users: "general", "eng-general", "engineering", "@username"
|
||||
|
||||
DO include:
|
||||
- Actual content: "performance", "bug", "deployment", "API", "database", "error", "feature"
|
||||
- Meta-words: "topics", "conversations", "discussed", "summary", "messages"
|
||||
- Temporal: "today", "yesterday", "week", "month", "recent", "last"
|
||||
- Channel names: "general", "eng-general", "random"
|
||||
|
||||
Examples:
|
||||
|
||||
Query: "what are the big topics in eng-general this week?"
|
||||
Output:
|
||||
|
||||
Query: "messages with Sarah about the deployment"
|
||||
Output:
|
||||
Sarah deployment
|
||||
Sarah
|
||||
deployment
|
||||
|
||||
Query: "what did Mike say about the budget?"
|
||||
Output:
|
||||
Mike budget
|
||||
Mike
|
||||
budget
|
||||
|
||||
Query: "performance issues in eng-general"
|
||||
Output:
|
||||
performance issues
|
||||
@@ -41,7 +50,7 @@ Now process this query:
|
||||
|
||||
{{query}}
|
||||
|
||||
Output:
|
||||
Output (keywords only, one per line, NO explanations or commentary):
|
||||
"""
|
||||
|
||||
SLACK_DATE_EXTRACTION_PROMPT = """
|
||||
|
||||
@@ -697,7 +697,7 @@ def save_user_credentials(
|
||||
# TODO: fix and/or type correctly w/base model
|
||||
config_data = MCPConnectionData(
|
||||
headers=auth_template.config.get("headers", {}),
|
||||
header_substitutions=auth_template.config.get(HEADER_SUBSTITUTIONS, {}),
|
||||
header_substitutions=request.credentials,
|
||||
)
|
||||
for oauth_field_key in MCPOAuthKeys:
|
||||
field_key: Literal["client_info", "tokens", "metadata"] = (
|
||||
|
||||
@@ -34,7 +34,7 @@ from onyx.db.persona import mark_persona_as_not_deleted
|
||||
from onyx.db.persona import update_persona_is_default
|
||||
from onyx.db.persona import update_persona_label
|
||||
from onyx.db.persona import update_persona_public_status
|
||||
from onyx.db.persona import update_persona_shared_users
|
||||
from onyx.db.persona import update_persona_shared
|
||||
from onyx.db.persona import update_persona_visibility
|
||||
from onyx.db.persona import update_personas_display_priority
|
||||
from onyx.file_store.file_store import get_default_file_store
|
||||
@@ -366,7 +366,9 @@ def delete_label(
|
||||
|
||||
|
||||
class PersonaShareRequest(BaseModel):
|
||||
user_ids: list[UUID]
|
||||
user_ids: list[UUID] | None = None
|
||||
group_ids: list[int] | None = None
|
||||
is_public: bool | None = None
|
||||
|
||||
|
||||
# We notify each user when a user is shared with them
|
||||
@@ -377,11 +379,13 @@ def share_persona(
|
||||
user: User = Depends(current_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> None:
|
||||
update_persona_shared_users(
|
||||
update_persona_shared(
|
||||
persona_id=persona_id,
|
||||
user_ids=persona_share_request.user_ids,
|
||||
user=user,
|
||||
db_session=db_session,
|
||||
user_ids=persona_share_request.user_ids,
|
||||
group_ids=persona_share_request.group_ids,
|
||||
is_public=persona_share_request.is_public,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -41,6 +41,12 @@ API_KEY_RECORDS: Dict[str, Dict[str, Any]] = {
|
||||
},
|
||||
}
|
||||
|
||||
# These are inferrable from the file anyways, no need to obfuscate.
|
||||
# use them to test your auth with this server
|
||||
#
|
||||
# mcp_live-kid_alice_001-S3cr3tAlice
|
||||
# mcp_live-kid_bob_001-S3cr3tBob
|
||||
|
||||
|
||||
# ---- verifier ---------------------------------------------------------------
|
||||
class ApiKeyVerifier(TokenVerifier):
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 548 B After Width: | Height: | Size: 581 B |
@@ -25,7 +25,7 @@ export default function OnyxApiKeyForm({
|
||||
|
||||
return (
|
||||
<Modal open onOpenChange={onClose}>
|
||||
<Modal.Content tall>
|
||||
<Modal.Content width="sm" height="lg">
|
||||
<Modal.Header
|
||||
icon={SvgKey}
|
||||
title={isUpdate ? "Update API Key" : "Create a new API Key"}
|
||||
|
||||
@@ -105,7 +105,7 @@ function Main() {
|
||||
{popup}
|
||||
|
||||
<Modal open={!!fullApiKey}>
|
||||
<Modal.Content small>
|
||||
<Modal.Content width="sm" height="sm">
|
||||
<Modal.Header
|
||||
title="New API Key"
|
||||
icon={SvgKey}
|
||||
|
||||
@@ -10,10 +10,7 @@ import {
|
||||
} from "@/lib/types";
|
||||
import BackButton from "@/refresh-components/buttons/BackButton";
|
||||
import { InstantSSRAutoRefresh } from "@/components/SSRAutoRefresh";
|
||||
import {
|
||||
FetchAssistantsResponse,
|
||||
fetchAssistantsSS,
|
||||
} from "@/lib/assistants/fetchAssistantsSS";
|
||||
import { FetchAssistantsResponse, fetchAssistantsSS } from "@/lib/agentsSS";
|
||||
import { getStandardAnswerCategoriesIfEE } from "@/components/standardAnswers/getStandardAnswerCategoriesIfEE";
|
||||
|
||||
async function EditslackChannelConfigPage(props: {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { fetchSS } from "@/lib/utilsSS";
|
||||
import { ErrorCallout } from "@/components/ErrorCallout";
|
||||
import { DocumentSetSummary, ValidSources } from "@/lib/types";
|
||||
import BackButton from "@/refresh-components/buttons/BackButton";
|
||||
import { fetchAssistantsSS } from "@/lib/assistants/fetchAssistantsSS";
|
||||
import { fetchAssistantsSS } from "@/lib/agentsSS";
|
||||
import { getStandardAnswerCategoriesIfEE } from "@/components/standardAnswers/getStandardAnswerCategoriesIfEE";
|
||||
import { redirect } from "next/navigation";
|
||||
import { SourceIcon } from "@/components/SourceIcon";
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useState, ReactNode } from "react";
|
||||
import useSWR, { useSWRConfig, KeyedMutator } from "swr";
|
||||
import { PopupSpec, usePopup } from "@/components/admin/connectors/Popup";
|
||||
import {
|
||||
LLMProviderView,
|
||||
ModelConfiguration,
|
||||
WellKnownLLMProviderDescriptor,
|
||||
} from "../../interfaces";
|
||||
import { errorHandlingFetcher } from "@/lib/fetcher";
|
||||
@@ -114,7 +115,7 @@ export function ProviderFormEntrypointWrapper({
|
||||
|
||||
{formIsVisible && (
|
||||
<Modal open onOpenChange={onClose}>
|
||||
<Modal.Content medium>
|
||||
<Modal.Content>
|
||||
<Modal.Header
|
||||
icon={SvgSettings}
|
||||
title={`Setup ${providerName}`}
|
||||
@@ -196,7 +197,7 @@ export function ProviderFormEntrypointWrapper({
|
||||
|
||||
{formIsVisible && (
|
||||
<Modal open onOpenChange={onClose}>
|
||||
<Modal.Content medium>
|
||||
<Modal.Content>
|
||||
<Modal.Header
|
||||
icon={SvgSettings}
|
||||
title={`${existingLlmProvider ? "Configure" : "Setup"} ${
|
||||
|
||||
@@ -130,7 +130,7 @@ export default function UpgradingPage({
|
||||
{popup}
|
||||
{isCancelling && (
|
||||
<Modal open onOpenChange={() => setIsCancelling(false)}>
|
||||
<Modal.Content small>
|
||||
<Modal.Content width="sm" height="sm">
|
||||
<Modal.Header
|
||||
icon={SvgX}
|
||||
title="Cancel Embedding Model Switch"
|
||||
|
||||
@@ -81,7 +81,7 @@ export const WebProviderSetupModal = memo(
|
||||
|
||||
return (
|
||||
<Modal open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<Modal.Content mini preventAccidentalClose>
|
||||
<Modal.Content width="sm" preventAccidentalClose>
|
||||
<Modal.Header
|
||||
icon={LogoArrangement}
|
||||
title={`Set up ${providerLabel}`}
|
||||
|
||||
@@ -125,7 +125,7 @@ export default function IndexAttemptErrorsModal({
|
||||
|
||||
return (
|
||||
<Modal open onOpenChange={onClose}>
|
||||
<Modal.Content large>
|
||||
<Modal.Content width="lg" height="full">
|
||||
<Modal.Header
|
||||
icon={SvgAlertTriangle}
|
||||
title="Indexing Errors"
|
||||
|
||||
@@ -353,7 +353,7 @@ export default function InlineFileManagement({
|
||||
|
||||
{/* Confirmation Modal */}
|
||||
<Modal open={showSaveConfirm} onOpenChange={setShowSaveConfirm}>
|
||||
<Modal.Content mini>
|
||||
<Modal.Content width="sm">
|
||||
<Modal.Header
|
||||
icon={SvgFolderPlus}
|
||||
title="Confirm File Changes"
|
||||
|
||||
@@ -128,7 +128,7 @@ export default function ReIndexModal({
|
||||
|
||||
return (
|
||||
<Modal open onOpenChange={hide}>
|
||||
<Modal.Content small>
|
||||
<Modal.Content width="sm" height="sm">
|
||||
<Modal.Header icon={SvgRefreshCw} title="Run Indexing" onClose={hide} />
|
||||
<Modal.Body>
|
||||
<Text as="p">
|
||||
|
||||
@@ -584,7 +584,7 @@ export default function AddConnector({
|
||||
open
|
||||
onOpenChange={() => setCreateCredentialFormToggle(false)}
|
||||
>
|
||||
<Modal.Content medium>
|
||||
<Modal.Content>
|
||||
<Modal.Header
|
||||
icon={SvgKey}
|
||||
title={`Create a ${getSourceDisplayName(
|
||||
|
||||
@@ -323,7 +323,7 @@ const RerankingDetailsForm = forwardRef<
|
||||
open
|
||||
onOpenChange={() => setShowGpuWarningModalModel(null)}
|
||||
>
|
||||
<Modal.Content small>
|
||||
<Modal.Content width="sm" height="sm">
|
||||
<Modal.Header
|
||||
icon={SvgAlertTriangle}
|
||||
title="GPU Not Enabled"
|
||||
@@ -358,7 +358,7 @@ const RerankingDetailsForm = forwardRef<
|
||||
setShowLiteLLMConfigurationModal(false);
|
||||
}}
|
||||
>
|
||||
<Modal.Content medium>
|
||||
<Modal.Content>
|
||||
<Modal.Header
|
||||
icon={SvgKey}
|
||||
title="API Key Configuration"
|
||||
@@ -462,7 +462,7 @@ const RerankingDetailsForm = forwardRef<
|
||||
setIsApiKeyModalOpen(false);
|
||||
}}
|
||||
>
|
||||
<Modal.Content medium>
|
||||
<Modal.Content>
|
||||
<Modal.Header
|
||||
icon={SvgKey}
|
||||
title="API Key Configuration"
|
||||
|
||||
@@ -14,7 +14,7 @@ export default function AlreadyPickedModal({
|
||||
}: AlreadyPickedModalProps) {
|
||||
return (
|
||||
<Modal open onOpenChange={onClose}>
|
||||
<Modal.Content small>
|
||||
<Modal.Content width="sm" height="sm">
|
||||
<Modal.Header
|
||||
icon={SvgCheck}
|
||||
title={`${model.model_name} already chosen`}
|
||||
|
||||
@@ -21,7 +21,7 @@ export default function DeleteCredentialsModal({
|
||||
}: DeleteCredentialsModalProps) {
|
||||
return (
|
||||
<Modal open onOpenChange={onCancel}>
|
||||
<Modal.Content small>
|
||||
<Modal.Content width="sm" height="sm">
|
||||
<Modal.Header
|
||||
icon={SvgTrash}
|
||||
title={`Delete ${getFormattedProviderName(
|
||||
|
||||
@@ -13,7 +13,7 @@ export default function InstantSwitchConfirmModal({
|
||||
}: InstantSwitchConfirmModalProps) {
|
||||
return (
|
||||
<Modal open onOpenChange={onClose}>
|
||||
<Modal.Content small>
|
||||
<Modal.Content width="sm" height="sm">
|
||||
<Modal.Header
|
||||
icon={SvgAlertTriangle}
|
||||
title="Are you sure you want to do an instant switch?"
|
||||
|
||||
@@ -20,7 +20,7 @@ export default function ModelSelectionConfirmationModal({
|
||||
}: ModelSelectionConfirmationModalProps) {
|
||||
return (
|
||||
<Modal open onOpenChange={onCancel}>
|
||||
<Modal.Content tall>
|
||||
<Modal.Content width="sm" height="lg">
|
||||
<Modal.Header
|
||||
icon={SvgServer}
|
||||
title="Update Embedding Model"
|
||||
|
||||
@@ -186,7 +186,7 @@ export default function ProviderCreationModal({
|
||||
|
||||
return (
|
||||
<Modal open onOpenChange={onCancel}>
|
||||
<Modal.Content small>
|
||||
<Modal.Content width="sm" height="sm">
|
||||
<Modal.Header
|
||||
icon={SvgSettings}
|
||||
title={`Configure ${getFormattedProviderName(
|
||||
|
||||
@@ -17,7 +17,7 @@ export default function SelectModelModal({
|
||||
}: SelectModelModalProps) {
|
||||
return (
|
||||
<Modal open onOpenChange={onCancel}>
|
||||
<Modal.Content small>
|
||||
<Modal.Content width="sm" height="sm">
|
||||
<Modal.Header
|
||||
icon={SvgServer}
|
||||
title={`Select ${model.model_name}`}
|
||||
|
||||
@@ -539,7 +539,7 @@ export default function EmbeddingForm() {
|
||||
)}
|
||||
{showPoorModel && (
|
||||
<Modal open onOpenChange={() => setShowPoorModel(false)}>
|
||||
<Modal.Content medium>
|
||||
<Modal.Content>
|
||||
<Modal.Header
|
||||
icon={SvgAlertTriangle}
|
||||
title={`Are you sure you want to select ${selectedProvider.model_name}?`}
|
||||
|
||||
@@ -299,7 +299,7 @@ function Main() {
|
||||
)}
|
||||
{configureModalShown && (
|
||||
<Modal open onOpenChange={() => setConfigureModalShown(false)}>
|
||||
<Modal.Content medium>
|
||||
<Modal.Content>
|
||||
<Modal.Header
|
||||
icon={SvgSettings}
|
||||
title="Configure Knowledge Graph"
|
||||
|
||||
@@ -308,7 +308,7 @@ export function SettingsForm() {
|
||||
)}
|
||||
{showConfirmModal && (
|
||||
<Modal open onOpenChange={() => setShowConfirmModal(false)}>
|
||||
<Modal.Content medium>
|
||||
<Modal.Content>
|
||||
<Modal.Header
|
||||
icon={SvgAlertTriangle}
|
||||
title="Enable Anonymous Users"
|
||||
|
||||
@@ -63,7 +63,7 @@ export default function CreateRateLimitModal({
|
||||
|
||||
return (
|
||||
<Modal open={isOpen} onOpenChange={() => setIsOpen(false)}>
|
||||
<Modal.Content small>
|
||||
<Modal.Content width="sm" height="sm">
|
||||
<Modal.Header
|
||||
icon={SvgSettings}
|
||||
title="Create a Token Rate Limit"
|
||||
|
||||
@@ -351,7 +351,7 @@ const AddUserButton = ({
|
||||
|
||||
{bulkAddUsersModal && (
|
||||
<Modal open onOpenChange={() => setBulkAddUsersModal(false)}>
|
||||
<Modal.Content medium>
|
||||
<Modal.Content>
|
||||
<Modal.Header
|
||||
icon={SvgUserPlus}
|
||||
title="Bulk Add Users"
|
||||
|
||||
@@ -580,7 +580,7 @@ export default function ChatPage({ firstMessage }: ChatPageProps) {
|
||||
open
|
||||
onOpenChange={() => updateCurrentDocumentSidebarVisible(false)}
|
||||
>
|
||||
<Modal.Content medium>
|
||||
<Modal.Content>
|
||||
<Modal.Header
|
||||
icon={SvgFileText}
|
||||
title="Sources"
|
||||
|
||||
@@ -73,7 +73,7 @@ export function ChatPopup() {
|
||||
|
||||
return (
|
||||
<Modal open onOpenChange={() => {}}>
|
||||
<Modal.Content tall>
|
||||
<Modal.Content width="sm" height="lg">
|
||||
<Modal.Header
|
||||
titleClassName="text-text-04"
|
||||
icon={headerIcon}
|
||||
|
||||
@@ -93,7 +93,7 @@ export default function FeedbackModal({
|
||||
{popup}
|
||||
|
||||
<Modal open={modal.isOpen} onOpenChange={modal.toggle}>
|
||||
<Modal.Content mini>
|
||||
<Modal.Content width="sm" height="sm">
|
||||
<Modal.Header
|
||||
icon={icon}
|
||||
title="Provide Additional Feedback"
|
||||
|
||||
@@ -68,7 +68,7 @@ export const CodeBlock = memo(function CodeBlock({
|
||||
"bg-background-tint-00",
|
||||
"rounded",
|
||||
"text-xs",
|
||||
"inline-block",
|
||||
"inline",
|
||||
"whitespace-pre-wrap",
|
||||
"break-words",
|
||||
"py-0.5",
|
||||
|
||||
@@ -10,8 +10,7 @@ import IconButton from "@/refresh-components/buttons/IconButton";
|
||||
import CopyIconButton from "@/refresh-components/buttons/CopyIconButton";
|
||||
import Button from "@/refresh-components/buttons/Button";
|
||||
import { SvgEdit } from "@opal/icons";
|
||||
import FileDisplay from "@/app/chat/message/FileDisplay";
|
||||
import { useTripleClickSelect } from "@/hooks/useTripleClickSelect";
|
||||
import FileDisplay from "./FileDisplay";
|
||||
|
||||
interface MessageEditingProps {
|
||||
content: string;
|
||||
@@ -140,10 +139,6 @@ const HumanMessage = React.memo(function HumanMessage({
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
// Ref for the text content element (for triple-click selection)
|
||||
const textContentRef = useRef<HTMLDivElement>(null);
|
||||
const handleTripleClick = useTripleClickSelect(textContentRef);
|
||||
|
||||
// Use nodeId for switching (finding position in siblings)
|
||||
const indexInSiblings = otherMessagesCanSwitchTo?.indexOf(nodeId);
|
||||
// indexOf returns -1 if not found, treat that as undefined
|
||||
@@ -200,34 +195,18 @@ const HumanMessage = React.memo(function HumanMessage({
|
||||
<>
|
||||
<div className="md:max-w-[25rem] flex basis-[100%] md:basis-auto justify-end md:order-1">
|
||||
<div
|
||||
ref={textContentRef}
|
||||
className={
|
||||
"max-w-[25rem] whitespace-break-spaces rounded-t-16 rounded-bl-16 bg-background-tint-02 py-2 px-3 cursor-text"
|
||||
"max-w-[25rem] whitespace-break-spaces rounded-t-16 rounded-bl-16 bg-background-tint-02 py-2 px-3"
|
||||
}
|
||||
onMouseDown={handleTripleClick}
|
||||
onCopy={(e) => {
|
||||
e.preventDefault();
|
||||
const selection = window.getSelection();
|
||||
if (!selection || !selection.rangeCount) {
|
||||
e.clipboardData.setData("text/plain", content);
|
||||
return;
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
const selectedText = selection
|
||||
.toString()
|
||||
.replace(/\n{2,}/g, "\n")
|
||||
.trim();
|
||||
|
||||
// Check if selection is within this element using DOM containment
|
||||
if (
|
||||
textContentRef.current?.contains(
|
||||
range.commonAncestorContainer
|
||||
)
|
||||
) {
|
||||
e.clipboardData.setData("text/plain", selectedText);
|
||||
} else {
|
||||
e.clipboardData.setData("text/plain", content);
|
||||
if (selection) {
|
||||
e.preventDefault();
|
||||
const text = selection
|
||||
.toString()
|
||||
.replace(/\n{2,}/g, "\n")
|
||||
.trim();
|
||||
e.clipboardData.setData("text/plain", text);
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -62,7 +62,6 @@ import { usePopup } from "@/components/admin/connectors/Popup";
|
||||
import { useFeedbackController } from "../../hooks/useFeedbackController";
|
||||
import { SvgThumbsDown, SvgThumbsUp } from "@opal/icons";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { useTripleClickSelect } from "@/hooks/useTripleClickSelect";
|
||||
|
||||
// Type for the regeneration factory function passed from ChatUI
|
||||
export type RegenerationFactory = (regenerationRequest: {
|
||||
@@ -126,7 +125,6 @@ const AIMessage = React.memo(function AIMessage({
|
||||
}: AIMessageProps) {
|
||||
const markdownRef = useRef<HTMLDivElement>(null);
|
||||
const finalAnswerRef = useRef<HTMLDivElement>(null);
|
||||
const handleTripleClick = useTripleClickSelect(markdownRef);
|
||||
const { popup, setPopup } = usePopup();
|
||||
const { handleFeedbackChange } = useFeedbackController({ setPopup });
|
||||
|
||||
@@ -538,8 +536,7 @@ const AIMessage = React.memo(function AIMessage({
|
||||
<div className="max-w-message-max break-words pl-4 w-full">
|
||||
<div
|
||||
ref={markdownRef}
|
||||
className="overflow-x-visible max-w-content-max focus:outline-none select-text cursor-text"
|
||||
onMouseDown={handleTripleClick}
|
||||
className="overflow-x-visible max-w-content-max focus:outline-none select-text"
|
||||
onCopy={(e) => {
|
||||
if (markdownRef.current) {
|
||||
handleCopy(e, markdownRef as RefObject<HTMLDivElement>);
|
||||
|
||||
@@ -461,7 +461,7 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
|
||||
/>
|
||||
|
||||
<Modal open={showTurnOffModal} onOpenChange={setShowTurnOffModal}>
|
||||
<Modal.Content mini>
|
||||
<Modal.Content width="sm">
|
||||
<Modal.Header
|
||||
icon={SvgAlertTriangle}
|
||||
title="Turn off Onyx new tab page?"
|
||||
@@ -483,7 +483,7 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
|
||||
|
||||
{!user && authTypeMetadata.authType !== AuthType.DISABLED && (
|
||||
<Modal open onOpenChange={() => {}}>
|
||||
<Modal.Content small>
|
||||
<Modal.Content width="sm" height="sm">
|
||||
<Modal.Header icon={SvgUser} title="Welcome to Onyx" />
|
||||
<Modal.Body>
|
||||
{authTypeMetadata.authType === AuthType.BASIC ? (
|
||||
|
||||
@@ -35,7 +35,7 @@ export default function UserGroupCreationForm({
|
||||
|
||||
return (
|
||||
<Modal open onOpenChange={onClose}>
|
||||
<Modal.Content medium>
|
||||
<Modal.Content>
|
||||
<Modal.Header
|
||||
icon={SvgUsers}
|
||||
title={isUpdate ? "Update a User Group" : "Create a new User Group"}
|
||||
|
||||
@@ -33,7 +33,7 @@ export default function AddConnectorForm({
|
||||
|
||||
return (
|
||||
<Modal open onOpenChange={onClose}>
|
||||
<Modal.Content small>
|
||||
<Modal.Content width="sm" height="sm">
|
||||
<Modal.Header
|
||||
icon={SvgPlus}
|
||||
title="Add New Connector"
|
||||
|
||||
@@ -22,7 +22,7 @@ export default function AddMemberForm({
|
||||
|
||||
return (
|
||||
<Modal open onOpenChange={onClose}>
|
||||
<Modal.Content small>
|
||||
<Modal.Content width="sm" height="sm">
|
||||
<Modal.Header
|
||||
icon={SvgUserPlus}
|
||||
title="Add New User"
|
||||
|
||||
@@ -4,8 +4,9 @@ import { use } from "react";
|
||||
import { GroupDisplay } from "./GroupDisplay";
|
||||
import { useSpecificUserGroup } from "./hook";
|
||||
import { ThreeDotsLoader } from "@/components/Loading";
|
||||
import { useConnectorStatus, useUsers } from "@/lib/hooks";
|
||||
import { useConnectorStatus } from "@/lib/hooks";
|
||||
import { useRouter } from "next/navigation";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
import BackButton from "@/refresh-components/buttons/BackButton";
|
||||
import { AdminPageTitle } from "@/components/admin/Title";
|
||||
import { SvgUsers } from "@opal/icons";
|
||||
|
||||
@@ -5,8 +5,9 @@ import UserGroupCreationForm from "./UserGroupCreationForm";
|
||||
import { usePopup } from "@/components/admin/connectors/Popup";
|
||||
import { useState } from "react";
|
||||
import { ThreeDotsLoader } from "@/components/Loading";
|
||||
import { useConnectorStatus, useUserGroups, useUsers } from "@/lib/hooks";
|
||||
import { useConnectorStatus, useUserGroups } from "@/lib/hooks";
|
||||
import { AdminPageTitle } from "@/components/admin/Title";
|
||||
import useUsers from "@/hooks/useUsers";
|
||||
|
||||
import { useUser } from "@/components/user/UserProvider";
|
||||
import CreateButton from "@/refresh-components/buttons/CreateButton";
|
||||
|
||||
@@ -178,7 +178,7 @@ function PreviousQueryHistoryExportsModal({
|
||||
|
||||
return (
|
||||
<Modal open onOpenChange={() => setShowModal(false)}>
|
||||
<Modal.Content large>
|
||||
<Modal.Content width="lg" height="full">
|
||||
<Modal.Header
|
||||
icon={SvgFileText}
|
||||
title="Previous Query History Exports"
|
||||
|
||||
@@ -30,13 +30,10 @@ export default function SourceTile({
|
||||
w-40
|
||||
cursor-pointer
|
||||
shadow-md
|
||||
bg-background-tint-00
|
||||
hover:bg-background-tint-02
|
||||
relative
|
||||
${
|
||||
preSelect
|
||||
? "bg-background-tint-01 subtle-pulse"
|
||||
: "bg-background-tint-00"
|
||||
}
|
||||
${preSelect ? "subtle-pulse" : ""}
|
||||
`}
|
||||
href={navigationUrl as Route}
|
||||
>
|
||||
|
||||
@@ -56,7 +56,7 @@ export default function ResetPasswordModal({
|
||||
|
||||
return (
|
||||
<Modal open onOpenChange={onClose}>
|
||||
<Modal.Content small>
|
||||
<Modal.Content width="sm" height="sm">
|
||||
<Modal.Header
|
||||
icon={SvgKey}
|
||||
title="Reset Password"
|
||||
|
||||
@@ -128,7 +128,7 @@ export default function FederatedOAuthModal() {
|
||||
|
||||
return (
|
||||
<Modal open>
|
||||
<Modal.Content small>
|
||||
<Modal.Content width="sm" height="sm">
|
||||
<Modal.Header
|
||||
icon={SvgLink}
|
||||
title="Connect Your Apps"
|
||||
|
||||
@@ -153,7 +153,7 @@ export default function MCPApiKeyModal({
|
||||
const credsType = isTemplateMode ? "Credentials" : "API Key";
|
||||
return (
|
||||
<Modal open={isOpen} onOpenChange={handleClose}>
|
||||
<Modal.Content small>
|
||||
<Modal.Content width="sm" height="sm">
|
||||
<Modal.Header
|
||||
icon={SvgKey}
|
||||
title={isAuthenticated ? `Manage ${credsType}` : `Enter ${credsType}`}
|
||||
|
||||
@@ -201,7 +201,8 @@ export default function TextView({
|
||||
}}
|
||||
>
|
||||
<Modal.Content
|
||||
large
|
||||
width="lg"
|
||||
height="full"
|
||||
preventAccidentalClose={false}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
|
||||
@@ -244,7 +244,7 @@ export default function CredentialSection({
|
||||
|
||||
{showModifyCredential && (
|
||||
<Modal open onOpenChange={closeModifyCredential}>
|
||||
<Modal.Content medium>
|
||||
<Modal.Content>
|
||||
<Modal.Header
|
||||
icon={SvgEdit}
|
||||
title="Update Credentials"
|
||||
@@ -272,7 +272,7 @@ export default function CredentialSection({
|
||||
|
||||
{editingCredential && (
|
||||
<Modal open onOpenChange={closeEditingCredential}>
|
||||
<Modal.Content medium>
|
||||
<Modal.Content>
|
||||
<Modal.Header
|
||||
icon={SvgEdit}
|
||||
title="Edit Credential"
|
||||
@@ -292,7 +292,7 @@ export default function CredentialSection({
|
||||
|
||||
{showCreateCredential && (
|
||||
<Modal open onOpenChange={closeCreateCredential}>
|
||||
<Modal.Content medium>
|
||||
<Modal.Content>
|
||||
<Modal.Header
|
||||
icon={SvgKey}
|
||||
title={`Create ${getSourceDisplayName(sourceType)} Credential`}
|
||||
|
||||
@@ -190,7 +190,7 @@ export default function ModifyCredential({
|
||||
<>
|
||||
{confirmDeletionCredential != null && (
|
||||
<Modal open onOpenChange={() => setConfirmDeletionCredential(null)}>
|
||||
<Modal.Content small>
|
||||
<Modal.Content width="sm" height="sm">
|
||||
<Modal.Header
|
||||
icon={SvgAlertTriangle}
|
||||
title="Confirm Deletion"
|
||||
|
||||
@@ -185,7 +185,7 @@ export const HealthCheckBanner = () => {
|
||||
if (showLoggedOutModal) {
|
||||
return (
|
||||
<Modal open>
|
||||
<Modal.Content small>
|
||||
<Modal.Content width="sm" height="sm">
|
||||
<Modal.Header icon={SvgLogOut} title="You Have Been Logged Out" />
|
||||
<Modal.Body>
|
||||
<p className="text-sm">
|
||||
|
||||
@@ -871,7 +871,7 @@ export const MicrosoftIconSVG = createLogoIcon(microsoftSVG);
|
||||
export const MistralIcon = createLogoIcon(mistralSVG);
|
||||
export const MixedBreadIcon = createLogoIcon(mixedBreadSVG);
|
||||
export const NomicIcon = createLogoIcon(nomicSVG);
|
||||
export const CodaIcon = createLogoIcon(codaIcon, { monochromatic: true });
|
||||
export const CodaIcon = createLogoIcon(codaIcon);
|
||||
export const NotionIcon = createLogoIcon(notionIcon, { monochromatic: true });
|
||||
export const OCIStorageIcon = createLogoIcon(OCIStorageSVG);
|
||||
export const OllamaIcon = createLogoIcon(ollamaIcon);
|
||||
|
||||
@@ -31,7 +31,7 @@ export default function AddInstructionModal() {
|
||||
|
||||
return (
|
||||
<Modal open={modal.isOpen} onOpenChange={modal.toggle}>
|
||||
<Modal.Content mini>
|
||||
<Modal.Content width="sm">
|
||||
<Modal.Header
|
||||
icon={SvgAddLines}
|
||||
title="Set Project Instructions"
|
||||
|
||||
@@ -35,7 +35,7 @@ export default function CreateProjectModal() {
|
||||
|
||||
return (
|
||||
<Modal open={modal.isOpen} onOpenChange={modal.toggle}>
|
||||
<Modal.Content mini>
|
||||
<Modal.Content width="sm">
|
||||
<Modal.Header
|
||||
icon={SvgFolderPlus}
|
||||
title="Create New Project"
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function EditPropertyModal({
|
||||
}: EditPropertyModalProps) {
|
||||
return (
|
||||
<Modal open onOpenChange={onClose}>
|
||||
<Modal.Content medium>
|
||||
<Modal.Content>
|
||||
<Modal.Header
|
||||
icon={SvgEdit}
|
||||
title={`Edit ${propertyTitle}`}
|
||||
|
||||
@@ -16,7 +16,7 @@ export default function ExceptionTraceModal({
|
||||
|
||||
return (
|
||||
<Modal open onOpenChange={onOutsideClick}>
|
||||
<Modal.Content large>
|
||||
<Modal.Content width="lg" height="full">
|
||||
<Modal.Header
|
||||
icon={SvgAlertTriangle}
|
||||
title="Full Exception Trace"
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function GenericConfirmModal({
|
||||
}: GenericConfirmModalProps) {
|
||||
return (
|
||||
<Modal open onOpenChange={onClose}>
|
||||
<Modal.Content small>
|
||||
<Modal.Content width="sm" height="sm">
|
||||
<Modal.Header icon={SvgCheck} title={title} onClose={onClose} />
|
||||
<Modal.Body>
|
||||
<Text as="p">{message}</Text>
|
||||
|
||||
@@ -11,7 +11,7 @@ export default function NoAssistantModal() {
|
||||
|
||||
return (
|
||||
<Modal open>
|
||||
<Modal.Content small>
|
||||
<Modal.Content width="sm" height="sm">
|
||||
<Modal.Header icon={SvgUser} title="No Assistant Available" />
|
||||
<Modal.Body>
|
||||
<Text as="p">
|
||||
|
||||
@@ -64,7 +64,7 @@ export default function ProviderModal({
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={handleOpenChange}>
|
||||
<Modal.Content tall onKeyDown={handleKeyDown}>
|
||||
<Modal.Content width="sm" height="lg" onKeyDown={handleKeyDown}>
|
||||
<Modal.Header
|
||||
icon={icon}
|
||||
title={title}
|
||||
|
||||
@@ -169,7 +169,8 @@ export default function UserFilesModal({
|
||||
|
||||
<Modal open={isOpen} onOpenChange={toggle}>
|
||||
<Modal.Content
|
||||
tall
|
||||
width="sm"
|
||||
height="lg"
|
||||
onOpenAutoFocus={(e) => {
|
||||
e.preventDefault();
|
||||
searchInputRef.current?.focus();
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
FullPersona,
|
||||
} from "@/app/admin/assistants/interfaces";
|
||||
import { errorHandlingFetcher } from "@/lib/fetcher";
|
||||
import { pinAgents } from "../lib/assistants/orderAssistants";
|
||||
import { pinAgents } from "@/lib/agents";
|
||||
import { useUser } from "@/components/user/UserProvider";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { SEARCH_PARAM_NAMES } from "@/app/chat/services/searchParams";
|
||||
@@ -75,7 +75,7 @@ export function useAgents() {
|
||||
* return <AgentEditor agent={agent} />;
|
||||
*/
|
||||
export function useAgent(agentId: number | null) {
|
||||
const { data, error, mutate } = useSWR<FullPersona>(
|
||||
const { data, error, isLoading, mutate } = useSWR<FullPersona>(
|
||||
agentId ? `/api/persona/${agentId}` : null,
|
||||
errorHandlingFetcher,
|
||||
{
|
||||
@@ -86,7 +86,7 @@ export function useAgent(agentId: number | null) {
|
||||
|
||||
return {
|
||||
agent: data ?? null,
|
||||
isLoading: !error && !data && agentId !== null,
|
||||
isLoading,
|
||||
error,
|
||||
refresh: mutate,
|
||||
};
|
||||
|
||||
64
web/src/hooks/useGroups.ts
Normal file
64
web/src/hooks/useGroups.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import useSWR from "swr";
|
||||
import { errorHandlingFetcher } from "@/lib/fetcher";
|
||||
import { UserGroup } from "@/lib/types";
|
||||
import { useContext } from "react";
|
||||
import { SettingsContext } from "@/components/settings/SettingsProvider";
|
||||
|
||||
/**
|
||||
* Fetches all user groups in the organization.
|
||||
*
|
||||
* Returns group information including group members, curators, and associated resources.
|
||||
* Use this for displaying group lists in sharing dialogs, admin panels, or permission
|
||||
* management interfaces.
|
||||
*
|
||||
* Note: This hook only returns data if enterprise features are enabled. In non-enterprise
|
||||
* environments, it returns an empty array.
|
||||
*
|
||||
* @returns Object containing:
|
||||
* - data: Array of UserGroup objects, or undefined while loading
|
||||
* - isLoading: Boolean indicating if data is being fetched
|
||||
* - error: Any error that occurred during fetch
|
||||
* - refreshGroups: Function to manually revalidate the data
|
||||
*
|
||||
* @example
|
||||
* // Fetch groups for sharing dialogs
|
||||
* const { data: groupsData, isLoading } = useGroups();
|
||||
* if (isLoading) return <Spinner />;
|
||||
* return <GroupList groups={groupsData ?? []} />;
|
||||
*
|
||||
* @example
|
||||
* // Fetch groups with manual refresh
|
||||
* const { data: groupsData, refreshGroups } = useGroups();
|
||||
* // Later...
|
||||
* await createNewGroup(...);
|
||||
* refreshGroups(); // Refresh the group list
|
||||
*/
|
||||
export default function useGroups() {
|
||||
const combinedSettings = useContext(SettingsContext);
|
||||
const isPaidEnterpriseFeaturesEnabled =
|
||||
combinedSettings && combinedSettings.enterpriseSettings !== null;
|
||||
|
||||
const { data, error, mutate, isLoading } = useSWR<UserGroup[]>(
|
||||
isPaidEnterpriseFeaturesEnabled ? "/api/manage/admin/user-group" : null,
|
||||
errorHandlingFetcher
|
||||
);
|
||||
|
||||
// If enterprise features are not enabled, return empty array
|
||||
if (!isPaidEnterpriseFeaturesEnabled) {
|
||||
return {
|
||||
data: [],
|
||||
isLoading: false,
|
||||
error: undefined,
|
||||
refreshGroups: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
data,
|
||||
isLoading,
|
||||
error,
|
||||
refreshGroups: mutate,
|
||||
};
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
|
||||
/**
|
||||
* Hook that implements standard triple-click text selection behavior:
|
||||
* - Single click: place cursor (browser default)
|
||||
* - Double click: select word (browser default)
|
||||
* - Triple click: select entire content of the target element
|
||||
*
|
||||
* Uses onMouseDown with event.detail to detect click count and preventDefault
|
||||
* on triple-click to avoid the native line selection flashing before our selection.
|
||||
*
|
||||
* @param elementRef - Ref to the element whose content should be selected on triple-click
|
||||
* @returns onMouseDown handler to attach to the element
|
||||
*/
|
||||
export function useTripleClickSelect(
|
||||
elementRef: React.RefObject<HTMLElement | null>
|
||||
) {
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
// event.detail gives the click count (1, 2, 3, etc.)
|
||||
if (e.detail === 3) {
|
||||
// Prevent native triple-click (line/paragraph selection)
|
||||
e.preventDefault();
|
||||
|
||||
const element = elementRef.current;
|
||||
if (!element) return;
|
||||
|
||||
const selection = window.getSelection();
|
||||
if (!selection) return;
|
||||
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(element);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
},
|
||||
[elementRef]
|
||||
);
|
||||
|
||||
return handleMouseDown;
|
||||
}
|
||||
52
web/src/hooks/useUsers.ts
Normal file
52
web/src/hooks/useUsers.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import useSWR from "swr";
|
||||
import { errorHandlingFetcher } from "@/lib/fetcher";
|
||||
import { AllUsersResponse } from "@/lib/types";
|
||||
|
||||
export interface UseUsersParams {
|
||||
includeApiKeys: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches all users in the organization.
|
||||
*
|
||||
* Returns user information including accepted users, invited users, and optionally
|
||||
* API key users. Use this for displaying user lists in sharing dialogs, admin panels,
|
||||
* or permission management interfaces.
|
||||
*
|
||||
* @param params - Configuration object
|
||||
* @param params.includeApiKeys - Whether to include API key users in the response
|
||||
*
|
||||
* @returns Object containing:
|
||||
* - data: AllUsersResponse containing accepted, invited, and API key users, or undefined while loading
|
||||
* - isLoading: Boolean indicating if data is being fetched
|
||||
* - error: Any error that occurred during fetch
|
||||
* - refreshUsers: Function to manually revalidate the data
|
||||
*
|
||||
* @example
|
||||
* // Fetch users without API keys (for sharing dialogs)
|
||||
* const { data: usersData, isLoading } = useUsers({ includeApiKeys: false });
|
||||
* if (isLoading) return <Spinner />;
|
||||
* return <UserList users={usersData?.accepted ?? []} />;
|
||||
*
|
||||
* @example
|
||||
* // Fetch all users including API keys (for admin panel)
|
||||
* const { data: usersData, refreshUsers } = useUsers({ includeApiKeys: true });
|
||||
* // Later...
|
||||
* await createNewUser(...);
|
||||
* refreshUsers(); // Refresh the user list
|
||||
*/
|
||||
export default function useUsers({ includeApiKeys }: UseUsersParams) {
|
||||
const { data, error, mutate, isLoading } = useSWR<AllUsersResponse>(
|
||||
`/api/manage/users?include_api_keys=${includeApiKeys}`,
|
||||
errorHandlingFetcher
|
||||
);
|
||||
|
||||
return {
|
||||
data,
|
||||
isLoading,
|
||||
error,
|
||||
refreshUsers: mutate,
|
||||
};
|
||||
}
|
||||
@@ -60,29 +60,19 @@ import React, {
|
||||
useContext,
|
||||
useState,
|
||||
useMemo,
|
||||
useRef,
|
||||
useLayoutEffect,
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
} from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { IconProps } from "@opal/types";
|
||||
import Truncated from "@/refresh-components/texts/Truncated";
|
||||
import { WithoutStyles } from "@/types";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { SvgMcp } from "@opal/icons";
|
||||
import ShadowDiv from "@/refresh-components/ShadowDiv";
|
||||
import { Section, SectionProps } from "@/layouts/general-layouts";
|
||||
|
||||
/**
|
||||
* Actions Layout Context
|
||||
*
|
||||
* Provides folding state management for action cards without prop drilling.
|
||||
*/
|
||||
interface ActionsLayoutContextValue {
|
||||
isFolded: boolean;
|
||||
setIsFolded: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
const ActionsLayoutContext = createContext<
|
||||
ActionsLayoutContextValue | undefined
|
||||
>(undefined);
|
||||
@@ -94,6 +84,7 @@ const ActionsLayoutContext = createContext<
|
||||
* - Provider: Context provider component to wrap action card
|
||||
* - isFolded: Current folding state
|
||||
* - setIsFolded: Function to update folding state
|
||||
* - hasContent: Whether an ActionsContent is currently mounted (read-only)
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
@@ -121,25 +112,56 @@ const ActionsLayoutContext = createContext<
|
||||
*/
|
||||
export function useActionsLayout() {
|
||||
const [isFolded, setIsFolded] = useState(false);
|
||||
const contextValue = useMemo(() => ({ isFolded, setIsFolded }), [isFolded]);
|
||||
const [hasContent, setHasContent] = useState(false);
|
||||
|
||||
// Wrap children directly, no component creation
|
||||
// Registration function for ActionsContent to announce its presence
|
||||
const registerContent = useMemo(
|
||||
() => () => {
|
||||
setHasContent(true);
|
||||
return () => setHasContent(false);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Use a ref to hold the context value so Provider can be stable.
|
||||
// Without this, changing contextValue would create a new Provider function,
|
||||
// which React treats as a different component type, causing unmount/remount
|
||||
// of all children (and losing focus on inputs).
|
||||
const contextValueRef = useRef<ActionsLayoutContextValue>(null!);
|
||||
contextValueRef.current = {
|
||||
isFolded,
|
||||
setIsFolded,
|
||||
hasContent,
|
||||
registerContent,
|
||||
};
|
||||
|
||||
// Stable Provider - reads from ref on each render, so the function
|
||||
// reference never changes but the provided value stays current.
|
||||
const Provider = useMemo(
|
||||
() =>
|
||||
({ children }: { children: React.ReactNode }) => (
|
||||
<ActionsLayoutContext.Provider value={contextValue}>
|
||||
<ActionsLayoutContext.Provider value={contextValueRef.current}>
|
||||
{children}
|
||||
</ActionsLayoutContext.Provider>
|
||||
),
|
||||
[contextValue]
|
||||
[]
|
||||
);
|
||||
|
||||
return { Provider, isFolded, setIsFolded };
|
||||
return { Provider, isFolded, setIsFolded, hasContent };
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal hook to access the ActionsLayout context.
|
||||
* Actions Layout Context
|
||||
*
|
||||
* Provides folding state management for action cards without prop drilling.
|
||||
* Also tracks whether content is present via self-registration.
|
||||
*/
|
||||
interface ActionsLayoutContextValue {
|
||||
isFolded: boolean;
|
||||
setIsFolded: Dispatch<SetStateAction<boolean>>;
|
||||
hasContent: boolean;
|
||||
registerContent: () => () => void;
|
||||
}
|
||||
function useActionsLayoutContext() {
|
||||
const context = useContext(ActionsLayoutContext);
|
||||
if (!context) {
|
||||
@@ -164,9 +186,7 @@ function useActionsLayoutContext() {
|
||||
* </ActionsLayouts.Root>
|
||||
* ```
|
||||
*/
|
||||
export type ActionsRootProps = SectionProps;
|
||||
|
||||
function ActionsRoot(props: ActionsRootProps) {
|
||||
function ActionsRoot(props: SectionProps) {
|
||||
return <Section gap={0} padding={0} {...props} />;
|
||||
}
|
||||
|
||||
@@ -204,19 +224,17 @@ function ActionsRoot(props: ActionsRootProps) {
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export type ActionsHeaderProps = WithoutStyles<
|
||||
{
|
||||
// Core content
|
||||
name?: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: React.FunctionComponent<IconProps>;
|
||||
|
||||
// Custom content
|
||||
rightChildren?: React.ReactNode;
|
||||
} & HtmlHTMLAttributes<HTMLDivElement>
|
||||
>;
|
||||
export interface ActionsHeaderProps
|
||||
extends WithoutStyles<HtmlHTMLAttributes<HTMLDivElement>> {
|
||||
// Core content
|
||||
name?: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: React.FunctionComponent<IconProps>;
|
||||
|
||||
// Custom content
|
||||
rightChildren?: React.ReactNode;
|
||||
}
|
||||
function ActionsHeader({
|
||||
name,
|
||||
title,
|
||||
@@ -226,13 +244,16 @@ function ActionsHeader({
|
||||
|
||||
...props
|
||||
}: ActionsHeaderProps) {
|
||||
const { isFolded } = useActionsLayoutContext();
|
||||
const { isFolded, hasContent } = useActionsLayoutContext();
|
||||
|
||||
// Round all corners if there's no content, or if content exists but is folded
|
||||
const shouldFullyRound = !hasContent || isFolded;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col border bg-background-neutral-00 w-full gap-2 pt-4 pb-2",
|
||||
isFolded ? "rounded-16" : "rounded-t-16"
|
||||
shouldFullyRound ? "rounded-16" : "rounded-t-16"
|
||||
)}
|
||||
>
|
||||
<label
|
||||
@@ -269,6 +290,13 @@ function ActionsHeader({
|
||||
* Use this to wrap tools, settings, or other expandable content.
|
||||
* Features a maximum height with scrollable overflow.
|
||||
*
|
||||
* IMPORTANT: Only ONE ActionsContent should be used within a single ActionsRoot.
|
||||
* This component self-registers with the ActionsLayout context to inform
|
||||
* ActionsHeader whether content exists (for border-radius styling). Using
|
||||
* multiple ActionsContent components will cause incorrect unmount behavior -
|
||||
* when any one unmounts, it will incorrectly signal that no content exists,
|
||||
* even if other ActionsContent components remain mounted.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ActionsLayouts.Content>
|
||||
@@ -277,19 +305,22 @@ function ActionsHeader({
|
||||
* </ActionsLayouts.Content>
|
||||
* ```
|
||||
*/
|
||||
export type ActionsContentProps = WithoutStyles<
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>;
|
||||
function ActionsContent(
|
||||
props: WithoutStyles<React.HTMLAttributes<HTMLDivElement>>
|
||||
) {
|
||||
const { isFolded, registerContent } = useActionsLayoutContext();
|
||||
|
||||
function ActionsContent(props: ActionsContentProps) {
|
||||
const { isFolded } = useActionsLayoutContext();
|
||||
// Self-register with context to inform Header that content exists
|
||||
useLayoutEffect(() => {
|
||||
return registerContent();
|
||||
}, [registerContent]);
|
||||
|
||||
if (isFolded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-x border-b rounded-b-16 overflow-hidden">
|
||||
<div className="border-x border-b rounded-b-16 overflow-hidden w-full">
|
||||
<ShadowDiv
|
||||
className="flex flex-col gap-2 rounded-b-16 max-h-[20rem] p-2"
|
||||
{...props}
|
||||
@@ -358,7 +389,6 @@ export type ActionsToolProps = WithoutStyles<{
|
||||
disabled?: boolean;
|
||||
rightChildren?: React.ReactNode;
|
||||
}>;
|
||||
|
||||
function ActionsTool({
|
||||
name,
|
||||
title,
|
||||
@@ -401,34 +431,6 @@ function ActionsTool({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Actions No Tools Found Component
|
||||
*
|
||||
* A simple empty state component that displays when no tools are found.
|
||||
* Shows the MCP icon with "No tools found" message.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ActionsLayouts.Content>
|
||||
* {tools.length === 0 ? (
|
||||
* <ActionsLayouts.NoToolsFound />
|
||||
* ) : (
|
||||
* tools.map(tool => <ActionsLayouts.Tool key={tool.id} {...tool} />)
|
||||
* )}
|
||||
* </ActionsLayouts.Content>
|
||||
* ```
|
||||
*/
|
||||
function ActionsNoToolsFound() {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2 p-4">
|
||||
<SvgMcp className="stroke-text-04" size={18} />
|
||||
<Text as="p" text03>
|
||||
No tools found
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Actions Tool Skeleton Component
|
||||
*
|
||||
@@ -486,6 +488,5 @@ export {
|
||||
ActionsHeader as Header,
|
||||
ActionsContent as Content,
|
||||
ActionsTool as Tool,
|
||||
ActionsNoToolsFound as NoToolsFound,
|
||||
ActionsToolSkeleton as ToolSkeleton,
|
||||
};
|
||||
|
||||
@@ -134,10 +134,10 @@ function HorizontalInputLayout({
|
||||
alignment
|
||||
)}
|
||||
>
|
||||
<div className="w-[70%]">
|
||||
<div className="min-w-[70%]">
|
||||
<LabelLayout {...fieldLabelProps} />
|
||||
</div>
|
||||
<div className="flex flex-col items-end min-w-[12rem]">{children}</div>
|
||||
<div className="flex flex-col items-end">{children}</div>
|
||||
</label>
|
||||
{name && <ErrorLayout name={name} />}
|
||||
</div>
|
||||
|
||||
168
web/src/lib/agents.ts
Normal file
168
web/src/lib/agents.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import {
|
||||
MinimalPersonaSnapshot,
|
||||
Persona,
|
||||
} from "@/app/admin/assistants/interfaces";
|
||||
import { User } from "./types";
|
||||
import { checkUserIsNoAuthUser } from "./user";
|
||||
import { personaComparator } from "@/app/admin/assistants/lib";
|
||||
|
||||
/**
|
||||
* Checks if the given user owns the specified assistant.
|
||||
*
|
||||
* @param user - The user to check ownership for, or null if no user is logged in
|
||||
* @param assistant - The assistant to check ownership of
|
||||
* @returns true if the user owns the assistant (or no auth is required), false otherwise
|
||||
*/
|
||||
export function checkUserOwnsAssistant(
|
||||
user: User | null,
|
||||
assistant: MinimalPersonaSnapshot | Persona
|
||||
) {
|
||||
return checkUserIdOwnsAssistant(user?.id, assistant);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the given user ID owns the specified assistant.
|
||||
*
|
||||
* Returns true if a valid user ID is provided and any of the following conditions
|
||||
* are met (and the assistant is not built-in):
|
||||
* - The user is a no-auth user (authentication is disabled)
|
||||
* - The user ID matches the assistant owner's ID
|
||||
*
|
||||
* Returns false if userId is undefined (e.g., user is loading or unauthenticated)
|
||||
* to prevent granting ownership access prematurely.
|
||||
*
|
||||
* @param userId - The user ID to check ownership for
|
||||
* @param assistant - The assistant to check ownership of
|
||||
* @returns true if the user owns the assistant, false otherwise
|
||||
*/
|
||||
export function checkUserIdOwnsAssistant(
|
||||
userId: string | undefined,
|
||||
assistant: MinimalPersonaSnapshot | Persona
|
||||
) {
|
||||
return (
|
||||
!!userId &&
|
||||
(checkUserIsNoAuthUser(userId) || assistant.owner?.id === userId) &&
|
||||
!assistant.builtin_persona
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the user's pinned assistants with the given ordered list of agent IDs.
|
||||
*
|
||||
* @param pinnedAgentIds - Array of agent IDs in the desired pinned order
|
||||
* @throws Error if the API request fails
|
||||
*/
|
||||
export async function pinAgents(pinnedAgentIds: number[]) {
|
||||
const response = await fetch(`/api/user/pinned-assistants`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
ordered_assistant_ids: pinnedAgentIds,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to update pinned assistants");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters and sorts assistants based on visibility.
|
||||
*
|
||||
* Only returns assistants that are marked as visible, sorted using the persona comparator.
|
||||
*
|
||||
* @param assistants - Array of assistants to filter
|
||||
* @returns Filtered and sorted array of visible assistants
|
||||
*/
|
||||
export function filterAssistants(
|
||||
assistants: MinimalPersonaSnapshot[]
|
||||
): MinimalPersonaSnapshot[] {
|
||||
let filteredAssistants = assistants.filter(
|
||||
(assistant) => assistant.is_visible
|
||||
);
|
||||
return filteredAssistants.sort(personaComparator);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an agent by its ID.
|
||||
*
|
||||
* @param agentId - The ID of the agent to delete
|
||||
* @returns null on success, or an error message string on failure
|
||||
*/
|
||||
export async function deleteAgent(agentId: number): Promise<string | null> {
|
||||
try {
|
||||
const response = await fetch(`/api/persona/${agentId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const errorMessage = (await response.json()).detail || "Unknown error";
|
||||
return errorMessage;
|
||||
} catch (error) {
|
||||
console.error("deleteAgent: Network error", error);
|
||||
return "Network error. Please check your connection and try again.";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates agent sharing settings.
|
||||
*
|
||||
* For MIT versions, group_ids should not be sent since group-based sharing
|
||||
* is an EE-only feature.
|
||||
*
|
||||
* @param agentId - The ID of the agent to update
|
||||
* @param userIds - Array of user IDs to share with
|
||||
* @param groupIds - Array of group IDs to share with (ignored when isPaidEnterpriseFeaturesEnabled is false)
|
||||
* @param isPublic - Whether the agent should be public
|
||||
* @param isPaidEnterpriseFeaturesEnabled - Whether enterprise features are enabled
|
||||
* @returns null on success, or an error message string on failure
|
||||
*
|
||||
* @example
|
||||
* const isPaidEnterpriseFeaturesEnabled = usePaidEnterpriseFeaturesEnabled();
|
||||
* const error = await updateAgentSharedStatus(agentId, userIds, groupIds, isPublic, isPaidEnterpriseFeaturesEnabled);
|
||||
* if (error) console.error(error);
|
||||
*/
|
||||
export async function updateAgentSharedStatus(
|
||||
agentId: number,
|
||||
userIds: string[],
|
||||
groupIds: number[],
|
||||
isPublic: boolean | undefined,
|
||||
isPaidEnterpriseFeaturesEnabled: boolean
|
||||
): Promise<null | string> {
|
||||
// MIT versions should not send group_ids - warn if caller provided non-empty groups
|
||||
if (!isPaidEnterpriseFeaturesEnabled && groupIds.length > 0) {
|
||||
console.error(
|
||||
"updateAgentSharedStatus: groupIds provided but enterprise features are disabled. " +
|
||||
"Group sharing is an EE-only feature. Discarding groupIds."
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/persona/${agentId}/share`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
user_ids: userIds,
|
||||
// Only include group_ids for enterprise versions
|
||||
group_ids: isPaidEnterpriseFeaturesEnabled ? groupIds : undefined,
|
||||
is_public: isPublic,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const errorMessage = (await response.json()).detail || "Unknown error";
|
||||
return errorMessage;
|
||||
} catch (error) {
|
||||
console.error("updateAgentSharedStatus: Network error", error);
|
||||
return "Network error. Please check your connection and try again.";
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { MinimalPersonaSnapshot } from "@/app/admin/assistants/interfaces";
|
||||
import { fetchSS } from "../utilsSS";
|
||||
import { fetchSS } from "./utilsSS";
|
||||
|
||||
export type FetchAssistantsResponse = [MinimalPersonaSnapshot[], string | null];
|
||||
|
||||
// Fetch assistants server-side
|
||||
export async function fetchAssistantsSS(): Promise<FetchAssistantsResponse> {
|
||||
const response = await fetchSS("/persona");
|
||||
if (response.ok) {
|
||||
@@ -1,25 +0,0 @@
|
||||
import {
|
||||
MinimalPersonaSnapshot,
|
||||
Persona,
|
||||
} from "@/app/admin/assistants/interfaces";
|
||||
import { User } from "../types";
|
||||
import { checkUserIsNoAuthUser } from "../user";
|
||||
|
||||
export function checkUserOwnsAssistant(
|
||||
user: User | null,
|
||||
assistant: MinimalPersonaSnapshot | Persona
|
||||
) {
|
||||
return checkUserIdOwnsAssistant(user?.id, assistant);
|
||||
}
|
||||
|
||||
export function checkUserIdOwnsAssistant(
|
||||
userId: string | undefined,
|
||||
assistant: MinimalPersonaSnapshot | Persona
|
||||
) {
|
||||
return (
|
||||
(!userId ||
|
||||
checkUserIsNoAuthUser(userId) ||
|
||||
assistant.owner?.id === userId) &&
|
||||
!assistant.builtin_persona
|
||||
);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
// Helper to persist pinned agents to the server
|
||||
export async function pinAgents(pinnedAgentIds: number[]) {
|
||||
const response = await fetch(`/api/user/pinned-assistants`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
ordered_assistant_ids: pinnedAgentIds,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to update pinned assistants");
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import { Persona } from "@/app/admin/assistants/interfaces";
|
||||
|
||||
interface ShareAssistantRequest {
|
||||
userIds: string[];
|
||||
assistantId: number;
|
||||
}
|
||||
|
||||
async function updateAssistantSharedStatus(
|
||||
request: ShareAssistantRequest
|
||||
): Promise<null | string> {
|
||||
const response = await fetch(`/api/persona/${request.assistantId}/share`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
user_ids: request.userIds,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const errorMessage = (await response.json()).detail || "Unknown error";
|
||||
return errorMessage;
|
||||
}
|
||||
|
||||
export async function addUsersToAssistantSharedList(
|
||||
existingAssistant: Persona,
|
||||
newUserIds: string[]
|
||||
): Promise<null | string> {
|
||||
// Merge existing user IDs with new user IDs, ensuring no duplicates
|
||||
const updatedUserIds = Array.from(
|
||||
new Set([...existingAssistant.users.map((user) => user.id), ...newUserIds])
|
||||
);
|
||||
|
||||
// Update the assistant's shared status with the new user list
|
||||
return updateAssistantSharedStatus({
|
||||
userIds: updatedUserIds,
|
||||
assistantId: existingAssistant.id,
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeUsersFromAssistantSharedList(
|
||||
existingAssistant: Persona,
|
||||
userIdsToRemove: string[]
|
||||
): Promise<null | string> {
|
||||
// Filter out the user IDs to be removed from the existing user list
|
||||
const updatedUserIds = existingAssistant.users
|
||||
.map((user) => user.id)
|
||||
.filter((id) => !userIdsToRemove.includes(id));
|
||||
|
||||
// Update the assistant's shared status with the new user list
|
||||
return updateAssistantSharedStatus({
|
||||
userIds: updatedUserIds,
|
||||
assistantId: existingAssistant.id,
|
||||
});
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
"use client";
|
||||
|
||||
export async function updateUserAssistantList(
|
||||
chosenAssistants: number[]
|
||||
): Promise<boolean> {
|
||||
const response = await fetch("/api/user/assistant-list", {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ chosen_assistants: chosenAssistants }),
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
}
|
||||
export async function updateAssistantVisibility(
|
||||
assistantId: number,
|
||||
show: boolean
|
||||
): Promise<boolean> {
|
||||
const response = await fetch(
|
||||
`/api/user/assistant-list/update/${assistantId}?show=${show}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return response.ok;
|
||||
}
|
||||
|
||||
export async function removeAssistantFromList(
|
||||
assistantId: number
|
||||
): Promise<boolean> {
|
||||
return updateAssistantVisibility(assistantId, false);
|
||||
}
|
||||
|
||||
export async function addAssistantToList(
|
||||
assistantId: number
|
||||
): Promise<boolean> {
|
||||
return updateAssistantVisibility(assistantId, true);
|
||||
}
|
||||
|
||||
export async function moveAssistantUp(
|
||||
assistantId: number,
|
||||
chosenAssistants: number[]
|
||||
): Promise<boolean> {
|
||||
const index = chosenAssistants.indexOf(assistantId);
|
||||
if (index > 0) {
|
||||
const chosenAssistantPrev = chosenAssistants[index - 1];
|
||||
const chosenAssistant = chosenAssistants[index];
|
||||
if (chosenAssistantPrev === undefined || chosenAssistant === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
chosenAssistants[index - 1] = chosenAssistant;
|
||||
chosenAssistants[index] = chosenAssistantPrev;
|
||||
return updateUserAssistantList(chosenAssistants);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function moveAssistantDown(
|
||||
assistantId: number,
|
||||
chosenAssistants: number[]
|
||||
): Promise<boolean> {
|
||||
const index = chosenAssistants.indexOf(assistantId);
|
||||
if (index < chosenAssistants.length - 1) {
|
||||
const chosenAssistantNext = chosenAssistants[index + 1];
|
||||
const chosenAssistant = chosenAssistants[index];
|
||||
if (chosenAssistantNext === undefined || chosenAssistant === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
chosenAssistants[index + 1] = chosenAssistant;
|
||||
chosenAssistants[index] = chosenAssistantNext;
|
||||
|
||||
return updateUserAssistantList(chosenAssistants);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export const reorderPinnedAssistants = async (
|
||||
assistantIds: number[]
|
||||
): Promise<boolean> => {
|
||||
const response = await fetch(`/api/user/pinned-assistants`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ ordered_assistant_ids: assistantIds }),
|
||||
});
|
||||
return response.ok;
|
||||
};
|
||||
@@ -1,136 +0,0 @@
|
||||
import { MinimalPersonaSnapshot } from "@/app/admin/assistants/interfaces";
|
||||
import { User } from "../types";
|
||||
import { checkUserIsNoAuthUser } from "../user";
|
||||
import { personaComparator } from "@/app/admin/assistants/lib";
|
||||
|
||||
export function checkUserOwnsAssistant(
|
||||
user: User | null,
|
||||
assistant: MinimalPersonaSnapshot
|
||||
) {
|
||||
return checkUserIdOwnsAssistant(user?.id, assistant);
|
||||
}
|
||||
|
||||
export function checkUserIdOwnsAssistant(
|
||||
userId: string | undefined,
|
||||
assistant: MinimalPersonaSnapshot
|
||||
) {
|
||||
return (
|
||||
(!userId ||
|
||||
checkUserIsNoAuthUser(userId) ||
|
||||
assistant.owner?.id === userId) &&
|
||||
!assistant.builtin_persona
|
||||
);
|
||||
}
|
||||
|
||||
export function classifyAssistants(
|
||||
user: User | null,
|
||||
assistants: MinimalPersonaSnapshot[]
|
||||
) {
|
||||
if (!user) {
|
||||
return {
|
||||
visibleAssistants: assistants.filter(
|
||||
(assistant) => assistant.is_default_persona
|
||||
),
|
||||
hiddenAssistants: [],
|
||||
};
|
||||
}
|
||||
|
||||
const visibleAssistants = assistants.filter((assistant) => {
|
||||
const isVisible = user.preferences?.visible_assistants?.includes(
|
||||
assistant.id
|
||||
);
|
||||
const isNotHidden = !user.preferences?.hidden_assistants?.includes(
|
||||
assistant.id
|
||||
);
|
||||
|
||||
const isBuiltIn = assistant.builtin_persona;
|
||||
const isDefault = assistant.is_default_persona;
|
||||
|
||||
const isOwnedByUser = checkUserOwnsAssistant(user, assistant);
|
||||
|
||||
const isShown =
|
||||
(isVisible && isNotHidden) ||
|
||||
(isNotHidden && (isBuiltIn || isDefault || isOwnedByUser));
|
||||
return isShown;
|
||||
});
|
||||
|
||||
const hiddenAssistants = assistants.filter((assistant) => {
|
||||
return !visibleAssistants.includes(assistant);
|
||||
});
|
||||
|
||||
return {
|
||||
visibleAssistants,
|
||||
hiddenAssistants,
|
||||
};
|
||||
}
|
||||
|
||||
export function orderAssistantsForUser(
|
||||
assistants: MinimalPersonaSnapshot[],
|
||||
user: User | null
|
||||
) {
|
||||
let orderedAssistants = [...assistants];
|
||||
|
||||
if (user?.preferences?.chosen_assistants) {
|
||||
const chosenAssistantsSet = new Set(user.preferences.chosen_assistants);
|
||||
const assistantOrderMap = new Map(
|
||||
user.preferences.chosen_assistants.map((id: number, index: number) => [
|
||||
id,
|
||||
index,
|
||||
])
|
||||
);
|
||||
|
||||
// Sort chosen assistants based on user preferences
|
||||
orderedAssistants.sort((a, b) => {
|
||||
const orderA = assistantOrderMap.get(a.id);
|
||||
const orderB = assistantOrderMap.get(b.id);
|
||||
|
||||
if (orderA !== undefined && orderB !== undefined) {
|
||||
return orderA - orderB;
|
||||
} else if (orderA !== undefined) {
|
||||
return -1;
|
||||
} else if (orderB !== undefined) {
|
||||
return 1;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
// Filter out assistants not in the user's chosen list
|
||||
orderedAssistants = orderedAssistants.filter((assistant) =>
|
||||
chosenAssistantsSet.has(assistant.id)
|
||||
);
|
||||
}
|
||||
|
||||
// Sort remaining assistants based on display_priority
|
||||
const remainingAssistants = assistants.filter(
|
||||
(assistant) => !orderedAssistants.includes(assistant)
|
||||
);
|
||||
|
||||
remainingAssistants.sort((a, b) => {
|
||||
const priorityA = a.display_priority ?? Number.MAX_SAFE_INTEGER;
|
||||
const priorityB = b.display_priority ?? Number.MAX_SAFE_INTEGER;
|
||||
return priorityA - priorityB;
|
||||
});
|
||||
|
||||
// Combine ordered chosen assistants with remaining assistants
|
||||
return [...orderedAssistants, ...remainingAssistants];
|
||||
}
|
||||
|
||||
export function getUserCreatedAssistants(
|
||||
user: User | null,
|
||||
assistants: MinimalPersonaSnapshot[]
|
||||
) {
|
||||
return assistants.filter((assistant) =>
|
||||
checkUserOwnsAssistant(user, assistant)
|
||||
);
|
||||
}
|
||||
|
||||
// Filter assistants based on connector status, image compatibility and visibility
|
||||
export function filterAssistants(
|
||||
assistants: MinimalPersonaSnapshot[]
|
||||
): MinimalPersonaSnapshot[] {
|
||||
let filteredAssistants = assistants.filter(
|
||||
(assistant) => assistant.is_visible
|
||||
);
|
||||
return filteredAssistants.sort(personaComparator);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { MinimalPersonaSnapshot } from "@/app/admin/assistants/interfaces";
|
||||
import { fetchAssistantsSS } from "../assistants/fetchAssistantsSS";
|
||||
import { filterAssistants } from "../assistants/utils";
|
||||
import { filterAssistants } from "@/lib/agents";
|
||||
import { fetchAssistantsSS } from "@/lib/agentsSS";
|
||||
|
||||
export async function fetchAssistantData(): Promise<MinimalPersonaSnapshot[]> {
|
||||
try {
|
||||
|
||||
@@ -462,21 +462,6 @@ export function useFilters(): FilterManager {
|
||||
};
|
||||
}
|
||||
|
||||
interface UseUsersParams {
|
||||
includeApiKeys: boolean;
|
||||
}
|
||||
|
||||
export const useUsers = ({ includeApiKeys }: UseUsersParams) => {
|
||||
const url = `/api/manage/users?include_api_keys=${includeApiKeys}`;
|
||||
|
||||
const swrResponse = useSWR<AllUsersResponse>(url, errorHandlingFetcher);
|
||||
|
||||
return {
|
||||
...swrResponse,
|
||||
refreshIndexingStatus: () => mutate(url),
|
||||
};
|
||||
};
|
||||
|
||||
export interface LlmDescriptor {
|
||||
name: string;
|
||||
provider: string;
|
||||
|
||||
@@ -10,12 +10,12 @@ import { useAppRouter } from "@/hooks/appNavigation";
|
||||
import IconButton from "@/refresh-components/buttons/IconButton";
|
||||
import Truncated from "@/refresh-components/texts/Truncated";
|
||||
import type { IconProps } from "@opal/types";
|
||||
import { usePinnedAgents } from "@/hooks/useAgents";
|
||||
import { usePinnedAgents, useAgent } from "@/hooks/useAgents";
|
||||
import { cn, noProp } from "@/lib/utils";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { Route } from "next";
|
||||
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
|
||||
import { checkUserOwnsAssistant } from "@/lib/assistants/utils";
|
||||
import { checkUserOwnsAssistant, updateAgentSharedStatus } from "@/lib/agents";
|
||||
import { useUser } from "@/components/user/UserProvider";
|
||||
import {
|
||||
SvgActions,
|
||||
@@ -24,8 +24,12 @@ import {
|
||||
SvgEdit,
|
||||
SvgPin,
|
||||
SvgPinned,
|
||||
SvgShare,
|
||||
SvgUser,
|
||||
} from "@opal/icons";
|
||||
import { useCreateModal } from "./contexts/ModalContext";
|
||||
import ShareAgentModal from "@/sections/modals/ShareAgentModal";
|
||||
import { usePopup } from "@/components/admin/connectors/Popup";
|
||||
interface IconLabelProps {
|
||||
icon: React.FunctionComponent<IconProps>;
|
||||
children: string;
|
||||
@@ -58,6 +62,9 @@ export default function AgentCard({ agent }: AgentCardProps) {
|
||||
const isPaidEnterpriseFeaturesEnabled = usePaidEnterpriseFeaturesEnabled();
|
||||
const isOwnedByUser = checkUserOwnsAssistant(user, agent);
|
||||
const [hovered, setHovered] = React.useState(false);
|
||||
const shareAgentModal = useCreateModal();
|
||||
const { agent: fullAgent, refresh: refreshAgent } = useAgent(agent.id);
|
||||
const { popup, setPopup } = usePopup();
|
||||
|
||||
// Start chat and auto-pin unpinned agents to the sidebar
|
||||
const handleStartChat = useCallback(() => {
|
||||
@@ -67,96 +74,146 @@ export default function AgentCard({ agent }: AgentCardProps) {
|
||||
route({ agentId: agent.id });
|
||||
}, [pinned, togglePinnedAgent, agent, route]);
|
||||
|
||||
// Handle sharing agent
|
||||
const handleShare = useCallback(
|
||||
async (userIds: string[], groupIds: number[], isPublic: boolean) => {
|
||||
const error = await updateAgentSharedStatus(
|
||||
agent.id,
|
||||
userIds,
|
||||
groupIds,
|
||||
isPublic,
|
||||
isPaidEnterpriseFeaturesEnabled
|
||||
);
|
||||
|
||||
if (error) {
|
||||
setPopup({
|
||||
type: "error",
|
||||
message: `Failed to share agent: ${error}`,
|
||||
});
|
||||
} else {
|
||||
// Revalidate the agent data to reflect the changes
|
||||
refreshAgent();
|
||||
shareAgentModal.toggle(false);
|
||||
}
|
||||
},
|
||||
[agent.id, isPaidEnterpriseFeaturesEnabled, refreshAgent, setPopup]
|
||||
);
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="group/AgentCard"
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
<div
|
||||
className="flex flex-col w-full text-left cursor-pointer"
|
||||
onClick={handleStartChat}
|
||||
<>
|
||||
{popup}
|
||||
|
||||
<shareAgentModal.Provider>
|
||||
<ShareAgentModal
|
||||
agent={agent}
|
||||
userIds={fullAgent?.users?.map((u) => u.id) ?? []}
|
||||
groupIds={fullAgent?.groups ?? []}
|
||||
isPublic={fullAgent?.is_public ?? false}
|
||||
onShare={handleShare}
|
||||
/>
|
||||
</shareAgentModal.Provider>
|
||||
|
||||
<Card
|
||||
className="group/AgentCard"
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
{/* Main Body */}
|
||||
<div className="flex flex-col items-center gap-1 p-1">
|
||||
<div className="flex flex-row items-center w-full gap-1">
|
||||
<div className="flex flex-row items-center w-full p-1.5 gap-1.5">
|
||||
<div className="px-0.5">
|
||||
<AgentAvatar agent={agent} size={18} />
|
||||
<div
|
||||
className="flex flex-col w-full text-left cursor-pointer"
|
||||
onClick={handleStartChat}
|
||||
>
|
||||
{/* Main Body */}
|
||||
<div className="flex flex-col items-center gap-1 p-1">
|
||||
<div className="flex flex-row items-center w-full gap-1">
|
||||
<div className="flex flex-row items-center w-full p-1.5 gap-1.5">
|
||||
<div className="px-0.5">
|
||||
<AgentAvatar agent={agent} size={18} />
|
||||
</div>
|
||||
<Truncated mainContentBody className="flex-1">
|
||||
{agent.name}
|
||||
</Truncated>
|
||||
</div>
|
||||
<Truncated mainContentBody className="flex-1">
|
||||
{agent.name}
|
||||
</Truncated>
|
||||
</div>
|
||||
<div className={cn("flex flex-row p-0.5 items-center")}>
|
||||
{isOwnedByUser && isPaidEnterpriseFeaturesEnabled && (
|
||||
<div className={cn("flex flex-row p-0.5 items-center")}>
|
||||
{isOwnedByUser && isPaidEnterpriseFeaturesEnabled && (
|
||||
<IconButton
|
||||
icon={SvgBarChart}
|
||||
tertiary
|
||||
onClick={noProp(() =>
|
||||
router.push(`/ee/assistants/stats/${agent.id}` as Route)
|
||||
)}
|
||||
tooltip="View Agent Stats"
|
||||
className="hidden group-hover/AgentCard:flex"
|
||||
/>
|
||||
)}
|
||||
{isOwnedByUser && (
|
||||
<IconButton
|
||||
icon={SvgEdit}
|
||||
tertiary
|
||||
onClick={noProp(() =>
|
||||
router.push(`/chat/agents/edit/${agent.id}` as Route)
|
||||
)}
|
||||
tooltip="Edit Agent"
|
||||
className="hidden group-hover/AgentCard:flex"
|
||||
/>
|
||||
)}
|
||||
{isOwnedByUser && (
|
||||
<IconButton
|
||||
icon={SvgShare}
|
||||
tertiary
|
||||
onClick={noProp(() => shareAgentModal.toggle(true))}
|
||||
tooltip="Share Agent"
|
||||
className="hidden group-hover/AgentCard:flex"
|
||||
/>
|
||||
)}
|
||||
<IconButton
|
||||
icon={SvgBarChart}
|
||||
icon={pinned ? SvgPinned : SvgPin}
|
||||
tertiary
|
||||
onClick={noProp(() =>
|
||||
router.push(`/ee/assistants/stats/${agent.id}` as Route)
|
||||
)}
|
||||
tooltip="View Agent Stats"
|
||||
className="hidden group-hover/AgentCard:flex"
|
||||
onClick={noProp(() => togglePinnedAgent(agent, !pinned))}
|
||||
tooltip={pinned ? "Unpin from Sidebar" : "Pin to Sidebar"}
|
||||
transient={hovered && pinned}
|
||||
className={cn(!pinned && "hidden group-hover/AgentCard:flex")}
|
||||
/>
|
||||
)}
|
||||
{isOwnedByUser && (
|
||||
<IconButton
|
||||
icon={SvgEdit}
|
||||
tertiary
|
||||
onClick={noProp(() =>
|
||||
router.push(`/chat/agents/edit/${agent.id}` as Route)
|
||||
)}
|
||||
tooltip="Edit Agent"
|
||||
className="hidden group-hover/AgentCard:flex"
|
||||
/>
|
||||
)}
|
||||
<IconButton
|
||||
icon={pinned ? SvgPinned : SvgPin}
|
||||
tertiary
|
||||
onClick={noProp(() => togglePinnedAgent(agent, !pinned))}
|
||||
tooltip={pinned ? "Unpin from Sidebar" : "Pin to Sidebar"}
|
||||
transient={hovered && pinned}
|
||||
className={cn(!pinned && "hidden group-hover/AgentCard:flex")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Text
|
||||
as="p"
|
||||
secondaryBody
|
||||
text03
|
||||
className="pb-1 px-2 w-full line-clamp-2 truncate whitespace-normal h-[2.2rem] break-words"
|
||||
>
|
||||
{agent.description}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* Footer section - bg-background-tint-01 */}
|
||||
<div className="bg-background-tint-01 p-1 flex flex-row items-end justify-between">
|
||||
{/* Left side - creator and actions */}
|
||||
<div className="flex flex-col gap-1 py-1 px-2">
|
||||
<IconLabel icon={SvgUser}>{agent.owner?.email || "Onyx"}</IconLabel>
|
||||
<IconLabel icon={SvgActions}>
|
||||
{agent.tools.length > 0
|
||||
? `${agent.tools.length} Action${
|
||||
agent.tools.length > 1 ? "s" : ""
|
||||
}`
|
||||
: "No Actions"}
|
||||
</IconLabel>
|
||||
</div>
|
||||
|
||||
{/* Right side - Start Chat button */}
|
||||
<div className="p-0.5">
|
||||
<Button
|
||||
tertiary
|
||||
rightIcon={SvgBubbleText}
|
||||
onClick={noProp(handleStartChat)}
|
||||
<Text
|
||||
as="p"
|
||||
secondaryBody
|
||||
text03
|
||||
className="pb-1 px-2 w-full line-clamp-2 truncate whitespace-normal h-[2.2rem] break-words"
|
||||
>
|
||||
Start Chat
|
||||
</Button>
|
||||
{agent.description}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* Footer section - bg-background-tint-01 */}
|
||||
<div className="bg-background-tint-01 p-1 flex flex-row items-end justify-between">
|
||||
{/* Left side - creator and actions */}
|
||||
<div className="flex flex-col gap-1 py-1 px-2">
|
||||
<IconLabel icon={SvgUser}>
|
||||
{agent.owner?.email || "Onyx"}
|
||||
</IconLabel>
|
||||
<IconLabel icon={SvgActions}>
|
||||
{agent.tools.length > 0
|
||||
? `${agent.tools.length} Action${
|
||||
agent.tools.length > 1 ? "s" : ""
|
||||
}`
|
||||
: "No Actions"}
|
||||
</IconLabel>
|
||||
</div>
|
||||
|
||||
{/* Right side - Start Chat button */}
|
||||
<div className="p-0.5">
|
||||
<Button
|
||||
tertiary
|
||||
rightIcon={SvgBubbleText}
|
||||
onClick={noProp(handleStartChat)}
|
||||
>
|
||||
Start Chat
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -54,13 +54,13 @@ const ModalOverlay = React.forwardRef<
|
||||
ModalOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
/**
|
||||
* Modal Context for managing close button ref, warning state, and size variant
|
||||
* Modal Context for managing close button ref, warning state, and height variant
|
||||
*/
|
||||
interface ModalContextValue {
|
||||
closeButtonRef: React.RefObject<HTMLDivElement | null>;
|
||||
hasAttemptedClose: boolean;
|
||||
setHasAttemptedClose: (value: boolean) => void;
|
||||
sizeVariant: "large" | "medium" | "small" | "tall" | "mini";
|
||||
height: keyof typeof heightClasses;
|
||||
}
|
||||
|
||||
const ModalContext = React.createContext<ModalContextValue | null>(null);
|
||||
@@ -73,16 +73,18 @@ const useModalContext = () => {
|
||||
return context;
|
||||
};
|
||||
|
||||
/**
|
||||
* Size class names mapping for modal variants
|
||||
*/
|
||||
const sizeClassNames = {
|
||||
large: ["w-[80dvw]", "h-[80dvh]"],
|
||||
medium: ["w-[60rem]", "h-fit"],
|
||||
small: ["w-[32rem]", "max-h-[30rem]"],
|
||||
tall: ["w-[32rem]", "max-h-[calc(100dvh-4rem)]"],
|
||||
mini: ["w-[32rem]", "h-fit"],
|
||||
} as const;
|
||||
const widthClasses = {
|
||||
lg: "w-[80dvw]",
|
||||
md: "w-[60rem]",
|
||||
sm: "w-[32rem]",
|
||||
};
|
||||
|
||||
const heightClasses = {
|
||||
fit: "h-fit",
|
||||
sm: "max-h-[30rem] overflow-y-auto",
|
||||
lg: "max-h-[calc(100dvh-4rem)] overflow-y-auto",
|
||||
full: "h-[80dvh] overflow-y-auto",
|
||||
};
|
||||
|
||||
/**
|
||||
* Modal Content Component
|
||||
@@ -91,25 +93,21 @@ const sizeClassNames = {
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // Using size variants
|
||||
* <Modal.Content large>
|
||||
* {/* Main modal: w-[80dvw] h-[80dvh] *\/}
|
||||
* // Using width and height props
|
||||
* <Modal.Content width="lg" height="full">
|
||||
* {/* Large modal: w-[80dvw] h-[80dvh] *\/}
|
||||
* </Modal.Content>
|
||||
*
|
||||
* <Modal.Content medium>
|
||||
* <Modal.Content width="md" height="fit">
|
||||
* {/* Medium modal: w-[60rem] h-fit *\/}
|
||||
* </Modal.Content>
|
||||
*
|
||||
* <Modal.Content small>
|
||||
* {/* Small modal: w-[32rem] h-[30rem] *\/}
|
||||
* <Modal.Content width="sm" height="sm">
|
||||
* {/* Small modal: w-[32rem] max-h-[30rem] *\/}
|
||||
* </Modal.Content>
|
||||
*
|
||||
* <Modal.Content tall>
|
||||
* {/* Tall modal: w-[32rem] *\/}
|
||||
* </Modal.Content>
|
||||
*
|
||||
* <Modal.Content mini>
|
||||
* {/* Mini modal: w-[32rem] h-fit *\/}
|
||||
* <Modal.Content width="sm" height="lg">
|
||||
* {/* Tall modal: w-[32rem] max-h-[calc(100dvh-4rem)] *\/}
|
||||
* </Modal.Content>
|
||||
*
|
||||
* // Custom size with className
|
||||
@@ -123,11 +121,8 @@ interface ModalContentProps
|
||||
extends WithoutStyles<
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
> {
|
||||
large?: boolean;
|
||||
medium?: boolean;
|
||||
small?: boolean;
|
||||
tall?: boolean;
|
||||
mini?: boolean;
|
||||
width?: keyof typeof widthClasses;
|
||||
height?: keyof typeof heightClasses;
|
||||
preventAccidentalClose?: boolean;
|
||||
skipOverlay?: boolean;
|
||||
}
|
||||
@@ -138,28 +133,14 @@ const ModalContent = React.forwardRef<
|
||||
(
|
||||
{
|
||||
children,
|
||||
large,
|
||||
medium,
|
||||
small,
|
||||
tall,
|
||||
mini,
|
||||
width = "md",
|
||||
height = "fit",
|
||||
preventAccidentalClose = true,
|
||||
skipOverlay = false,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const variant = large
|
||||
? "large"
|
||||
: medium
|
||||
? "medium"
|
||||
: small
|
||||
? "small"
|
||||
: tall
|
||||
? "tall"
|
||||
: mini
|
||||
? "mini"
|
||||
: "medium";
|
||||
const closeButtonRef = React.useRef<HTMLDivElement>(null);
|
||||
const [hasAttemptedClose, setHasAttemptedClose] = React.useState(false);
|
||||
const hasUserTypedRef = React.useRef(false);
|
||||
@@ -275,7 +256,7 @@ const ModalContent = React.forwardRef<
|
||||
closeButtonRef,
|
||||
hasAttemptedClose,
|
||||
setHasAttemptedClose,
|
||||
sizeVariant: variant,
|
||||
height,
|
||||
}}
|
||||
>
|
||||
<DialogPrimitive.Portal>
|
||||
@@ -302,8 +283,9 @@ const ModalContent = React.forwardRef<
|
||||
"data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95",
|
||||
"data-[state=open]:slide-in-from-top-1/2 data-[state=closed]:slide-out-to-top-1/2",
|
||||
"duration-200",
|
||||
// Size variants
|
||||
sizeClassNames[variant]
|
||||
// Size classes
|
||||
widthClasses[width],
|
||||
heightClasses[height]
|
||||
)}
|
||||
onOpenAutoFocus={(e) => {
|
||||
// Reset typing detection when modal opens
|
||||
@@ -431,20 +413,12 @@ interface ModalBodyProps extends WithoutStyles<SectionProps> {
|
||||
}
|
||||
const ModalBody = React.forwardRef<HTMLDivElement, ModalBodyProps>(
|
||||
({ twoTone = true, children, ...props }, ref) => {
|
||||
const { sizeVariant } = useModalContext();
|
||||
|
||||
// Apply overflow-auto for fixed height variants (large, small, tall)
|
||||
const hasFixedHeight =
|
||||
sizeVariant === "large" ||
|
||||
sizeVariant === "small" ||
|
||||
sizeVariant === "tall";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
twoTone && "bg-background-tint-01",
|
||||
hasFixedHeight && "overflow-auto min-h-0"
|
||||
"min-h-0 overflow-y-auto"
|
||||
)}
|
||||
>
|
||||
<Section padding={1} gap={1} alignItems="start" {...props}>
|
||||
@@ -493,3 +467,31 @@ export default Object.assign(ModalRoot, {
|
||||
Body: ModalBody,
|
||||
Footer: ModalFooter,
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Common Layouts
|
||||
// ============================================================================
|
||||
|
||||
export interface BasicModalFooterProps {
|
||||
left?: React.ReactNode;
|
||||
cancel?: React.ReactNode;
|
||||
submit?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function BasicModalFooter({
|
||||
left,
|
||||
cancel,
|
||||
submit,
|
||||
}: BasicModalFooterProps) {
|
||||
return (
|
||||
<>
|
||||
{left && <Section alignItems="start">{left}</Section>}
|
||||
{(cancel || submit) && (
|
||||
<Section flexDirection="row" justifyContent="end" gap={0.5}>
|
||||
{cancel}
|
||||
{submit}
|
||||
</Section>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -95,20 +95,23 @@ const PopoverClose = PopoverPrimitive.Close;
|
||||
* ```
|
||||
*/
|
||||
const widthClasses = {
|
||||
main: "w-fit",
|
||||
wide: "w-[280px]",
|
||||
fit: "w-fit",
|
||||
md: "w-[12rem]",
|
||||
lg: "w-[18rem]",
|
||||
};
|
||||
interface PopoverContentProps
|
||||
extends WithoutStyles<
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
> {
|
||||
wide?: boolean;
|
||||
fit?: boolean;
|
||||
md?: boolean;
|
||||
lg?: boolean;
|
||||
}
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ComponentRef<typeof PopoverPrimitive.Content>,
|
||||
PopoverContentProps
|
||||
>(({ wide, align = "center", sideOffset = 4, ...props }, ref) => {
|
||||
const width = wide ? "wide" : "main";
|
||||
>(({ fit, md, lg, align = "center", sideOffset = 4, ...props }, ref) => {
|
||||
const width = fit ? "fit" : md ? "md" : lg ? "lg" : "fit";
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
@@ -133,11 +136,6 @@ function SeparatorHelper() {
|
||||
return <Separator className="py-0 px-2" />;
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
small: "w-[10rem]",
|
||||
medium: "w-[15.5rem]",
|
||||
};
|
||||
|
||||
export default Object.assign(PopoverRoot, {
|
||||
Trigger: PopoverTrigger,
|
||||
Anchor: PopoverAnchor,
|
||||
@@ -186,10 +184,16 @@ export default Object.assign(PopoverRoot, {
|
||||
* </Popover.Menu>
|
||||
* ```
|
||||
*/
|
||||
const sizeClasses = {
|
||||
sm: "w-[10rem]",
|
||||
md: "w-[16rem]",
|
||||
full: "!w-full",
|
||||
};
|
||||
export interface PopoverMenuProps {
|
||||
// size variants
|
||||
small?: boolean;
|
||||
medium?: boolean;
|
||||
sm?: boolean;
|
||||
md?: boolean;
|
||||
full?: boolean;
|
||||
|
||||
children?: React.ReactNode[];
|
||||
footer?: React.ReactNode;
|
||||
@@ -198,8 +202,9 @@ export interface PopoverMenuProps {
|
||||
scrollContainerRef?: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
export function PopoverMenu({
|
||||
small,
|
||||
medium,
|
||||
sm,
|
||||
md,
|
||||
full,
|
||||
|
||||
children,
|
||||
footer,
|
||||
@@ -214,7 +219,7 @@ export function PopoverMenu({
|
||||
if (child !== null) return true;
|
||||
return index !== 0 && index !== definedChildren.length - 1;
|
||||
});
|
||||
const size = small ? "small" : medium ? "medium" : "small";
|
||||
const size = full ? "full" : sm ? "sm" : md ? "md" : "full";
|
||||
|
||||
return (
|
||||
<Section alignItems="stretch">
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function SimplePopover({
|
||||
<Popover.Trigger asChild>
|
||||
<div>{typeof trigger === "function" ? trigger(open) : trigger}</div>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content align="start" side="top" {...rest} />
|
||||
<Popover.Content align="start" side="top" md {...rest} />
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,36 @@
|
||||
/**
|
||||
* SimpleTooltip - A wrapper component for easily adding tooltips to elements.
|
||||
*
|
||||
* IMPORTANT: Children must be ref-compatible (either a DOM element or a component
|
||||
* that uses forwardRef). This is required because TooltipTrigger uses `asChild`
|
||||
* which needs to attach a ref to the child element for positioning.
|
||||
*
|
||||
* Valid children:
|
||||
* - DOM elements: <div>, <button>, <span>, etc.
|
||||
* - forwardRef components: Components wrapped with React.forwardRef()
|
||||
*
|
||||
* Invalid children (will cause errors or warnings):
|
||||
* - Fragments: <>{content}</>
|
||||
* - Regular function components that don't forward refs
|
||||
* - Multiple children
|
||||
*
|
||||
* @example
|
||||
* // Valid - DOM element
|
||||
* <SimpleTooltip tooltip="Hello">
|
||||
* <button>Hover me</button>
|
||||
* </SimpleTooltip>
|
||||
*
|
||||
* // Valid - forwardRef component
|
||||
* <SimpleTooltip tooltip="Card tooltip">
|
||||
* <Card>Content</Card>
|
||||
* </SimpleTooltip>
|
||||
*
|
||||
* // Invalid - will cause React warning
|
||||
* <SimpleTooltip tooltip="Won't work">
|
||||
* <NonForwardRefComponent />
|
||||
* </SimpleTooltip>
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
@@ -32,25 +65,12 @@ export default function SimpleTooltip({
|
||||
tooltip ?? (typeof children === "string" ? children : undefined);
|
||||
|
||||
// If no hover content, just render children without tooltip
|
||||
if (!hoverContent) return <>{children}</>;
|
||||
|
||||
// TooltipTrigger `asChild` expects a ref-aware DOM element; wrap anything
|
||||
// else in a span so non-forwardRef components and fragments don't crash.
|
||||
const isDomElement =
|
||||
React.isValidElement(children) && typeof children.type === "string";
|
||||
|
||||
const triggerChild = isDomElement ? children : <span>{children}</span>;
|
||||
if (!hoverContent) return children;
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
asChild
|
||||
// Doesn't work for some reason.
|
||||
// disabled={disabled}
|
||||
>
|
||||
{triggerChild}
|
||||
</TooltipTrigger>
|
||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||
{!disabled && (
|
||||
<TooltipContent side={side} className={className} {...rest}>
|
||||
<Text as="p" textLight05>
|
||||
|
||||
@@ -81,12 +81,7 @@ const TabsList = React.forwardRef<
|
||||
>((props, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className="grid w-full rounded-08 bg-background-tint-03"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${React.Children.count(
|
||||
props.children
|
||||
)}, 1fr)`,
|
||||
}}
|
||||
className="flex w-full rounded-08 bg-background-tint-03"
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
@@ -176,7 +171,7 @@ const TabsTrigger = React.forwardRef<
|
||||
ref={ref}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-08 p-2 gap-2",
|
||||
"flex-1 inline-flex items-center justify-center whitespace-nowrap rounded-08 p-2 gap-2",
|
||||
|
||||
// active/inactive states:
|
||||
"data-[state=active]:bg-background-neutral-00 data-[state=active]:text-text-04 data-[state=active]:shadow-01 data-[state=active]:border",
|
||||
@@ -200,7 +195,7 @@ const TabsTrigger = React.forwardRef<
|
||||
if (tooltip && disabled) {
|
||||
return (
|
||||
<SimpleTooltip tooltip={tooltip} side={tooltipSide}>
|
||||
<span className="inline-flex align-middle justify-center">
|
||||
<span className="flex-1 inline-flex align-middle justify-center">
|
||||
{trigger}
|
||||
</span>
|
||||
</SimpleTooltip>
|
||||
|
||||
@@ -157,7 +157,9 @@ const LineItem = React.forwardRef<HTMLButtonElement, LineItemProps>(
|
||||
>
|
||||
{children}
|
||||
</Truncated>
|
||||
{rightChildren}
|
||||
{rightChildren && (
|
||||
<Section alignItems="end">{rightChildren}</Section>
|
||||
)}
|
||||
</Section>
|
||||
{description && (
|
||||
<Text as="p" secondaryBody text03>
|
||||
|
||||
@@ -50,21 +50,30 @@ const classNames = {
|
||||
export interface CardProps extends GeneralLayouts.SectionProps {
|
||||
// card variants
|
||||
translucent?: boolean;
|
||||
borderless?: boolean;
|
||||
disabled?: boolean;
|
||||
ref?: React.Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export default function Card({
|
||||
translucent,
|
||||
borderless,
|
||||
disabled,
|
||||
|
||||
padding = 1,
|
||||
|
||||
ref,
|
||||
...props
|
||||
}: CardProps) {
|
||||
const variant = translucent ? "translucent" : disabled ? "disabled" : "main";
|
||||
|
||||
return (
|
||||
<div className={cn("rounded-16 w-full h-full", classNames[variant])}>
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-16 w-full h-full",
|
||||
classNames[variant],
|
||||
borderless && "border-none"
|
||||
)}
|
||||
>
|
||||
<GeneralLayouts.Section alignItems="start" padding={padding} {...props} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -113,6 +113,7 @@ import { ComboBoxDropdown } from "./components/ComboBoxDropdown";
|
||||
// Types
|
||||
import { InputComboBoxProps, ComboBoxOption } from "./types";
|
||||
import { SvgChevronDown, SvgChevronUp } from "@opal/icons";
|
||||
import { WithoutStyles } from "@/types";
|
||||
|
||||
const InputComboBox = ({
|
||||
value,
|
||||
@@ -128,9 +129,8 @@ const InputComboBox = ({
|
||||
leftSearchIcon = false,
|
||||
rightSection,
|
||||
separatorLabel = "Other options",
|
||||
className,
|
||||
...rest
|
||||
}: InputComboBoxProps) => {
|
||||
}: WithoutStyles<InputComboBoxProps>) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const fieldContext = useContext(FieldContext);
|
||||
@@ -363,7 +363,7 @@ const InputComboBox = ({
|
||||
}, [isOpen, inputValue, value, options, hasOptions]);
|
||||
|
||||
return (
|
||||
<div ref={refs.setReference} className={cn("relative w-full", className)}>
|
||||
<div ref={refs.setReference} className="relative w-full">
|
||||
<>
|
||||
<InputTypeIn
|
||||
ref={inputRef}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import type { IconProps } from "@opal/types";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import Button from "@/refresh-components/buttons/Button";
|
||||
import Modal from "@/refresh-components/Modal";
|
||||
import { useModalClose } from "../contexts/ModalContext";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
|
||||
export interface ConfirmationModalProps {
|
||||
icon: React.FunctionComponent<IconProps>;
|
||||
@@ -31,7 +32,7 @@ export default function ConfirmationModalLayout({
|
||||
|
||||
return (
|
||||
<Modal open onOpenChange={(isOpen) => !isOpen && onClose?.()}>
|
||||
<Modal.Content mini>
|
||||
<Modal.Content width="sm">
|
||||
<Modal.Header
|
||||
icon={icon}
|
||||
title={title}
|
||||
|
||||
@@ -54,7 +54,7 @@ export default function SwitchList({
|
||||
}, [items, searchTerm]);
|
||||
|
||||
return (
|
||||
<PopoverMenu medium footer={footer}>
|
||||
<PopoverMenu md footer={footer}>
|
||||
{[
|
||||
<div className="flex items-center gap-1" key="search">
|
||||
<IconButton
|
||||
|
||||
@@ -438,7 +438,17 @@ export default function ActionsPopover({
|
||||
serverId: server.id,
|
||||
serverName: server.name,
|
||||
authTemplate: server.auth_template,
|
||||
onSuccess: undefined,
|
||||
onSuccess: () => {
|
||||
// Update the authentication state after successful credential submission
|
||||
setMcpServerData((prev) => ({
|
||||
...prev,
|
||||
[server.id]: {
|
||||
...prev[server.id],
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
},
|
||||
}));
|
||||
},
|
||||
isAuthenticated: server.user_authenticated,
|
||||
existingCredentials: server.user_credentials,
|
||||
});
|
||||
@@ -563,7 +573,7 @@ export default function ActionsPopover({
|
||||
);
|
||||
|
||||
const primaryView = (
|
||||
<PopoverMenu medium>
|
||||
<PopoverMenu md>
|
||||
{[
|
||||
<InputTypeIn
|
||||
key="search"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user