Compare commits

..

9 Commits

Author SHA1 Message Date
Nik
6fc7c0a863 feat(admin): expand user search to name, add SCIM sync status, fix table columns
- Backend `q` param now searches both email and personal_name (OR)
- Add `is_scim_synced` field to FullUserSnapshot via batch ScimUserMapping lookup
- Status column shows "SCIM synced" sublabel for SCIM-managed users
- Fix table columns: Name header, larger group pills, role icons, plain text status
- Fix header spacing (Spacer 2.5rem) to prevent content peeking above sticky header
- Simplify UsersSummary to plain static cells (no hover/filter behavior)
2026-03-08 19:41:39 -07:00
Nik
61bce0239a refactor(admin): extract types to interfaces.ts and data fetching to useAdminUsers hook
Move UserRow, PaginatedUsersResponse, and StatusFilter types into a
shared interfaces module. Extract paginated user fetching into a
dedicated useAdminUsers hook. Use Content variant="section" for the
User column to get title + description layout from the component
library instead of hand-rolling with div + Text.
2026-03-08 18:56:02 -07:00
Nik
04327ae3fc feat(admin): wire up enriched user fields in table columns
Add Name (personal_name with email subtitle), Groups (tag pills with
+N overflow), and Last Updated (timeAgo) columns to the users table.
2026-03-08 18:56:02 -07:00
Nik
f961700470 feat(admin): add Users table with DataTable and server-side pagination
- Create UsersTable component using DataTable with server-side mode
- Columns: avatar qualifier (initials from email), email, role, status tag
- Search input with server-side filtering via q param
- Pagination with "Showing X~Y of Z" summary footer
- Wire into UsersPage below the stats bar
2026-03-08 18:56:02 -07:00
Nik
7a9ed6f987 feat(admin): add user timestamps and enrich FullUserSnapshot
- Alembic migration adding created_at/updated_at to user table
- Add personal_name, created_at, updated_at, and groups to FullUserSnapshot
- Batch query for user group memberships to avoid N+1 in list endpoint
- Replace direct FullUserSnapshot construction with from_user_model()
2026-03-08 18:55:43 -07:00
Nik
a5bb2d8130 fix(admin): move Users v2 to Permissions sidebar section and fix stats bar layout
- Move Users v2 sidebar entry from User Management to Permissions section
  (alongside SCIM) to match Figma mocks
- Stats bar: use single card for all stats (not separate cards per stat)
  when SCIM is disabled
- Hide "requests to join" cell when count is 0 (not just null)
- Add onStatClick callback prop for future filter integration
2026-03-08 18:55:25 -07:00
Nik
dbbca1c4e5 refactor(admin): extract useUserCounts hook and rename StatsBar to UsersSummary
Move SWR data fetching out of UsersPage component into a dedicated
useUserCounts hook. Rename StatsBar → UsersSummary for clarity.
2026-03-08 16:05:33 -07:00
Nik
ff92928b31 fix(admin): refine stats bar layout and move to refresh-pages
- Replace LineItem with custom StatCell using Text to fix label truncation
- Use width="full" on StatCell for even distribution when fewer cells render
- Move UsersPage to refresh-pages pattern, route file re-exports
- Delete dead users2/StatsBar.tsx (replaced by UsersPage/StatsBar.tsx)
- Move sidebar entry from Permissions to User Management section
2026-03-08 14:31:09 -07:00
Nik
f452a777cc feat(admin): add Users v2 page shell with stats bar and SCIM card
New /admin/users2 page with SettingsLayouts, stat cards for active
users / pending invites / requests to join (cloud-only), and a
conditional SCIM sync card. Also adds 40px top padding to the
shared SettingsLayouts.Root for all admin pages.

Sidebar entry is commented out until the page is feature-complete.
2026-03-06 12:34:34 -08:00
41 changed files with 886 additions and 2342 deletions

View File

@@ -106,34 +106,13 @@ onyx-cli ask --json "What authentication methods do we support?"
Outputs JSON-encoded parsed stream events (one object per line). Key event objects include message deltas, stop, errors, search-start, and citation payloads.
Each line is a JSON object with this envelope:
```json
{"type": "<event_type>", "event": { ... }}
```
| Event Type | Description |
|------------|-------------|
| `message_delta` | Content token — concatenate all `content` fields for the full answer |
| `stop` | Stream complete |
| `error` | Error with `error` message field |
| `search_tool_start` | Onyx started searching documents |
| `citation_info` | Source citation — see shape below |
`citation_info` event shape:
```json
{
"type": "citation_info",
"event": {
"citation_number": 1,
"document_id": "abc123def456",
"placement": {"turn_index": 0, "tab_index": 0, "sub_turn_index": null}
}
}
```
`placement` is metadata about where in the conversation the citation appeared and can be ignored for most use cases.
| `citation_info` | Source citation with `citation_number` and `document_id` |
### Specify an agent
@@ -150,10 +129,6 @@ Uses a specific Onyx agent/persona instead of the default.
| `--agent-id` | int | Agent ID to use (overrides default) |
| `--json` | bool | Output raw NDJSON events instead of plain text |
## Statelessness
Each `onyx-cli ask` call creates an independent chat session. There is no built-in way to chain context across multiple `ask` invocations — every call starts fresh. If you need multi-turn conversation with memory, use the interactive TUI (`onyx-cli` or `onyx-cli chat`) instead.
## When to Use
Use `onyx-cli ask` when:

View File

@@ -57,7 +57,7 @@ jobs:
cache-dependency-path: ./desktop/package-lock.json
- name: Setup Rust
uses: dtolnay/rust-toolchain@efa25f7f19611383d5b0ccf2d1c8914531636bf9
uses: dtolnay/rust-toolchain@4be9e76fd7c4901c61fb841f559994984270fce7
with:
toolchain: stable
targets: ${{ matrix.target }}

View File

@@ -26,6 +26,7 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # ratchet:actions/checkout@v6
with:
persist-credentials: false
fetch-depth: 0
- uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # ratchet:astral-sh/setup-uv@v7
with:
enable-cache: false

View File

@@ -22,10 +22,12 @@ jobs:
- { goos: "windows", goarch: "arm64" }
- { goos: "darwin", goarch: "amd64" }
- { goos: "darwin", goarch: "arm64" }
- { goos: "", goarch: "" }
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # ratchet:actions/checkout@v6
with:
persist-credentials: false
fetch-depth: 0
- uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # ratchet:astral-sh/setup-uv@v7
with:
enable-cache: false

View File

@@ -0,0 +1,43 @@
"""add timestamps to user table
Revision ID: 27fb147a843f
Revises: a3b8d9e2f1c4
Create Date: 2026-03-08 17:18:40.828644
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "27fb147a843f"
down_revision = "a3b8d9e2f1c4"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"user",
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
)
op.add_column(
"user",
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
)
def downgrade() -> None:
op.drop_column("user", "updated_at")
op.drop_column("user", "created_at")

View File

@@ -280,6 +280,16 @@ class User(SQLAlchemyBaseUserTableUUID, Base):
TIMESTAMPAware(timezone=True), nullable=True
)
created_at: Mapped[datetime.datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime.datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
default_model: Mapped[str] = mapped_column(Text, nullable=True)
# organized in typical structured fashion
# formatted as `displayName__provider__modelName`

View File

@@ -11,6 +11,7 @@ from sqlalchemy.orm import Session
from sqlalchemy.sql import expression
from sqlalchemy.sql.elements import ColumnElement
from sqlalchemy.sql.elements import KeyedColumnElement
from sqlalchemy.sql.expression import or_
from onyx.auth.invited_users import remove_user_from_invited_users
from onyx.auth.schemas import UserRole
@@ -24,6 +25,7 @@ from onyx.db.models import Persona__User
from onyx.db.models import SamlAccount
from onyx.db.models import User
from onyx.db.models import User__UserGroup
from onyx.db.models import UserGroup
from onyx.utils.variable_functionality import fetch_ee_implementation_or_noop
@@ -162,7 +164,13 @@ def _get_accepted_user_where_clause(
where_clause.append(User.role != UserRole.EXT_PERM_USER)
if email_filter_string is not None:
where_clause.append(email_col.ilike(f"%{email_filter_string}%"))
personal_name_col: KeyedColumnElement[Any] = User.__table__.c.personal_name
where_clause.append(
or_(
email_col.ilike(f"%{email_filter_string}%"),
personal_name_col.ilike(f"%{email_filter_string}%"),
)
)
if roles_filter:
where_clause.append(User.role.in_(roles_filter))
@@ -358,3 +366,28 @@ def delete_user_from_db(
# NOTE: edge case may exist with race conditions
# with this `invited user` scheme generally.
remove_user_from_invited_users(user_to_delete.email)
def batch_get_user_groups(
db_session: Session,
user_ids: list[UUID],
) -> dict[UUID, list[tuple[int, str]]]:
"""Fetch group memberships for a batch of users in a single query.
Returns a mapping of user_id -> list of (group_id, group_name) tuples."""
if not user_ids:
return {}
rows = db_session.execute(
select(
User__UserGroup.user_id,
UserGroup.id,
UserGroup.name,
)
.join(UserGroup, UserGroup.id == User__UserGroup.user_group_id)
.where(User__UserGroup.user_id.in_(user_ids))
).all()
result: dict[UUID, list[tuple[int, str]]] = {uid: [] for uid in user_ids}
for user_id, group_id, group_name in rows:
result[user_id].append((group_id, group_name))
return result

View File

@@ -1,103 +0,0 @@
# Vector DB Filter Semantics
How `IndexFilters` fields combine into the final query filter. Applies to both Vespa and OpenSearch.
## Filter categories
| Category | Fields | Join logic |
|---|---|---|
| **Visibility** | `hidden` | Always applied (unless `include_hidden`) |
| **Tenant** | `tenant_id` | AND (multi-tenant only) |
| **ACL** | `access_control_list` | OR within, AND with rest |
| **Narrowing** | `source_type`, `tags`, `time_cutoff` | Each OR within, AND with rest |
| **Knowledge scope** | `document_set`, `user_file_ids`, `attached_document_ids`, `hierarchy_node_ids` | OR within group, AND with rest |
| **Additive scope** | `project_id`, `persona_id` | OR'd into knowledge scope **only when** a knowledge scope filter already exists |
## How filters combine
All categories are AND'd together. Within the knowledge scope category, individual filters are OR'd.
```
NOT hidden
AND tenant = T -- if multi-tenant
AND (acl contains A1 OR acl contains A2)
AND (source_type = S1 OR ...) -- if set
AND (tag = T1 OR ...) -- if set
AND <knowledge scope> -- see below
AND time >= cutoff -- if set
```
## Knowledge scope rules
The knowledge scope filter controls **what knowledge an assistant can access**.
### No explicit knowledge attached
When `document_set`, `user_file_ids`, `attached_document_ids`, and `hierarchy_node_ids` are all empty/None:
- **No knowledge scope filter is applied.** The assistant can see everything (subject to ACL).
- `project_id` and `persona_id` are ignored — they never restrict on their own.
### One explicit knowledge type
```
-- Only document sets
AND (document_sets contains "Engineering" OR document_sets contains "Legal")
-- Only user files
AND (document_id = "uuid-1" OR document_id = "uuid-2")
```
### Multiple explicit knowledge types (OR'd)
```
-- Document sets + user files
AND (
document_sets contains "Engineering"
OR document_id = "uuid-1"
)
```
### Explicit knowledge + overflowing user files
When an explicit knowledge restriction is in effect **and** `project_id` or `persona_id` is set (user files overflowed the LLM context window), the additive scopes widen the filter:
```
-- Document sets + persona user files overflowed
AND (
document_sets contains "Engineering"
OR personas contains 42
)
-- User files + project files overflowed
AND (
document_id = "uuid-1"
OR user_project contains 7
)
```
### Only project_id or persona_id (no explicit knowledge)
No knowledge scope filter. The assistant searches everything.
```
-- Just ACL, no restriction
NOT hidden
AND (acl contains ...)
```
## Field reference
| Filter field | Vespa field | Vespa type | Purpose |
|---|---|---|---|
| `document_set` | `document_sets` | `weightedset<string>` | Connector doc sets attached to assistant |
| `user_file_ids` | `document_id` | `string` | User files uploaded to assistant |
| `attached_document_ids` | `document_id` | `string` | Documents explicitly attached (OpenSearch only) |
| `hierarchy_node_ids` | `ancestor_hierarchy_node_ids` | `array<int>` | Folder/space nodes (OpenSearch only) |
| `project_id` | `user_project` | `array<int>` | Project tag for overflowing user files |
| `persona_id` | `personas` | `array<int>` | Persona tag for overflowing user files |
| `access_control_list` | `access_control_list` | `weightedset<string>` | ACL entries for the requesting user |
| `source_type` | `source_type` | `string` | Connector source type (e.g. `web`, `jira`) |
| `tags` | `metadata_list` | `array<string>` | Document metadata tags |
| `time_cutoff` | `doc_updated_at` | `long` | Minimum document update timestamp |
| `tenant_id` | `tenant_id` | `string` | Tenant isolation (multi-tenant) |

View File

@@ -698,6 +698,41 @@ class DocumentQuery:
"""
return {"terms": {ANCESTOR_HIERARCHY_NODE_IDS_FIELD_NAME: node_ids}}
def _get_assistant_knowledge_filter(
attached_doc_ids: list[str] | None,
node_ids: list[int] | None,
file_ids: list[UUID] | None,
document_sets: list[str] | None,
) -> dict[str, Any]:
"""Combined filter for assistant knowledge.
When an assistant has attached knowledge, search should be scoped to:
- Documents explicitly attached (by document ID), OR
- Documents under attached hierarchy nodes (by ancestor node IDs), OR
- User-uploaded files attached to the assistant, OR
- Documents in the assistant's document sets (if any)
"""
knowledge_filter: dict[str, Any] = {
"bool": {"should": [], "minimum_should_match": 1}
}
if attached_doc_ids:
knowledge_filter["bool"]["should"].append(
_get_attached_document_id_filter(attached_doc_ids)
)
if node_ids:
knowledge_filter["bool"]["should"].append(
_get_hierarchy_node_filter(node_ids)
)
if file_ids:
knowledge_filter["bool"]["should"].append(
_get_user_file_id_filter(file_ids)
)
if document_sets:
knowledge_filter["bool"]["should"].append(
_get_document_set_filter(document_sets)
)
return knowledge_filter
filter_clauses: list[dict[str, Any]] = []
if not include_hidden:
@@ -723,53 +758,41 @@ class DocumentQuery:
# document's metadata list.
filter_clauses.append(_get_tag_filter(tags))
# Knowledge scope: explicit knowledge attachments restrict what
# an assistant can see. When none are set the assistant
# searches everything.
#
# project_id / persona_id are additive: they make overflowing
# user files findable but must NOT trigger the restriction on
# their own (an agent with no explicit knowledge should search
# everything).
has_knowledge_scope = (
# Check if this is an assistant knowledge search (has any assistant-scoped knowledge)
has_assistant_knowledge = (
attached_document_ids
or hierarchy_node_ids
or user_file_ids
or document_sets
)
if has_knowledge_scope:
knowledge_filter: dict[str, Any] = {
"bool": {"should": [], "minimum_should_match": 1}
}
if attached_document_ids:
knowledge_filter["bool"]["should"].append(
_get_attached_document_id_filter(attached_document_ids)
if has_assistant_knowledge:
# If assistant has attached knowledge, scope search to that knowledge.
# Document sets are included in the OR filter so directly attached
# docs are always findable even if not in the document sets.
filter_clauses.append(
_get_assistant_knowledge_filter(
attached_document_ids,
hierarchy_node_ids,
user_file_ids,
document_sets,
)
if hierarchy_node_ids:
knowledge_filter["bool"]["should"].append(
_get_hierarchy_node_filter(hierarchy_node_ids)
)
if user_file_ids:
knowledge_filter["bool"]["should"].append(
_get_user_file_id_filter(user_file_ids)
)
if document_sets:
knowledge_filter["bool"]["should"].append(
_get_document_set_filter(document_sets)
)
# Additive: widen scope to also cover overflowing user
# files, but only when an explicit restriction is already
# in effect.
if project_id is not None:
knowledge_filter["bool"]["should"].append(
_get_user_project_filter(project_id)
)
if persona_id is not None:
knowledge_filter["bool"]["should"].append(
_get_persona_filter(persona_id)
)
filter_clauses.append(knowledge_filter)
)
elif user_file_ids:
# Fallback for non-assistant user file searches (e.g., project searches)
# If at least one user file ID is provided, the caller will only
# retrieve documents where the document ID is in this input list of
# file IDs.
filter_clauses.append(_get_user_file_id_filter(user_file_ids))
if project_id is not None:
# If a project ID is provided, the caller will only retrieve
# documents where the project ID provided here is present in the
# document's user projects list.
filter_clauses.append(_get_user_project_filter(project_id))
if persona_id is not None:
filter_clauses.append(_get_persona_filter(persona_id))
if time_cutoff is not None:
# If a time cutoff is provided, the caller will only retrieve

View File

@@ -23,8 +23,11 @@ from shared_configs.configs import MULTI_TENANT
logger = setup_logger()
def build_tenant_id_filter(tenant_id: str) -> str:
return f'({TENANT_ID} contains "{tenant_id}")'
def build_tenant_id_filter(tenant_id: str, include_trailing_and: bool = False) -> str:
filter_str = f'({TENANT_ID} contains "{tenant_id}")'
if include_trailing_and:
filter_str += " and "
return filter_str
def build_vespa_filters(
@@ -34,22 +37,30 @@ def build_vespa_filters(
remove_trailing_and: bool = False, # Set to True when using as a complete Vespa query
) -> str:
def _build_or_filters(key: str, vals: list[str] | None) -> str:
"""For string-based 'contains' filters, e.g. WSET fields or array<string> fields.
Returns a bare clause like '(key contains "v1" or key contains "v2")' or ""."""
"""For string-based 'contains' filters, e.g. WSET fields or array<string> fields."""
if not key or not vals:
return ""
eq_elems = [f'{key} contains "{val}"' for val in vals if val]
if not eq_elems:
return ""
return f"({' or '.join(eq_elems)})"
or_clause = " or ".join(eq_elems)
return f"({or_clause}) and "
def _build_int_or_filters(key: str, vals: list[int] | None) -> str:
"""For an integer field filter.
Returns a bare clause or ""."""
"""
For an integer field filter.
If vals is not None, we want *only* docs whose key matches one of vals.
"""
# If `vals` is None => skip the filter entirely
if vals is None or not vals:
return ""
# Otherwise build the OR filter
eq_elems = [f"{key} = {val}" for val in vals]
return f"({' or '.join(eq_elems)})"
or_clause = " or ".join(eq_elems)
result = f"({or_clause}) and "
return result
def _build_kg_filter(
kg_entities: list[str] | None,
@@ -62,12 +73,16 @@ def build_vespa_filters(
combined_filter_parts = []
def _build_kge(entity: str) -> str:
# TYPE-SUBTYPE::ID -> "TYPE-SUBTYPE::ID"
# TYPE-SUBTYPE::* -> ({prefix: true}"TYPE-SUBTYPE")
# TYPE::* -> ({prefix: true}"TYPE")
GENERAL = "::*"
if entity.endswith(GENERAL):
return f'({{prefix: true}}"{entity.split(GENERAL, 1)[0]}")'
else:
return f'"{entity}"'
# OR the entities (give new design)
if kg_entities:
filter_parts = []
for kg_entity in kg_entities:
@@ -89,7 +104,8 @@ def build_vespa_filters(
# TODO: remove kg terms entirely from prompts and codebase
return f"({' and '.join(combined_filter_parts)})"
# AND the combined filter parts
return f"({' and '.join(combined_filter_parts)}) and "
def _build_kg_source_filters(
kg_sources: list[str] | None,
@@ -98,14 +114,16 @@ def build_vespa_filters(
return ""
source_phrases = [f'{DOCUMENT_ID} contains "{source}"' for source in kg_sources]
return f"({' or '.join(source_phrases)})"
return f"({' or '.join(source_phrases)}) and "
def _build_kg_chunk_id_zero_only_filter(
kg_chunk_id_zero_only: bool,
) -> str:
if not kg_chunk_id_zero_only:
return ""
return "(chunk_id = 0)"
return "(chunk_id = 0 ) and "
def _build_time_filter(
cutoff: datetime | None,
@@ -117,8 +135,8 @@ def build_vespa_filters(
cutoff_secs = int(cutoff.timestamp())
if include_untimed:
return f"!({DOC_UPDATED_AT} < {cutoff_secs})"
return f"({DOC_UPDATED_AT} >= {cutoff_secs})"
return f"!({DOC_UPDATED_AT} < {cutoff_secs}) and "
return f"({DOC_UPDATED_AT} >= {cutoff_secs}) and "
def _build_user_project_filter(
project_id: int | None,
@@ -129,7 +147,8 @@ def build_vespa_filters(
pid = int(project_id)
except Exception:
return ""
return f'({USER_PROJECT} contains "{pid}")'
# Vespa YQL 'contains' expects a string literal; quote the integer
return f'({USER_PROJECT} contains "{pid}") and '
def _build_persona_filter(
persona_id: int | None,
@@ -141,94 +160,73 @@ def build_vespa_filters(
except Exception:
logger.warning(f"Invalid persona ID: {persona_id}")
return ""
return f'({PERSONAS} contains "{pid}")'
return f'({PERSONAS} contains "{pid}") and '
def _append(parts: list[str], clause: str) -> None:
if clause:
parts.append(clause)
# Collect all top-level filter clauses, then join with " and " at the end.
filter_parts: list[str] = []
if not include_hidden:
filter_parts.append(f"!({HIDDEN}=true)")
# Start building the filter string
filter_str = f"!({HIDDEN}=true) and " if not include_hidden else ""
# TODO: add error condition if MULTI_TENANT and no tenant_id filter is set
# If running in multi-tenant mode
if filters.tenant_id and MULTI_TENANT:
filter_parts.append(build_tenant_id_filter(filters.tenant_id))
filter_str += build_tenant_id_filter(
filters.tenant_id, include_trailing_and=True
)
# ACL filters
if filters.access_control_list is not None:
_append(
filter_parts,
_build_or_filters(ACCESS_CONTROL_LIST, filters.access_control_list),
filter_str += _build_or_filters(
ACCESS_CONTROL_LIST, filters.access_control_list
)
# Source type filters
source_strs = (
[s.value for s in filters.source_type] if filters.source_type else None
)
_append(filter_parts, _build_or_filters(SOURCE_TYPE, source_strs))
filter_str += _build_or_filters(SOURCE_TYPE, source_strs)
# Tag filters
tag_attributes = None
if filters.tags:
# build e.g. "tag_key|tag_value"
tag_attributes = [
f"{tag.tag_key}{INDEX_SEPARATOR}{tag.tag_value}" for tag in filters.tags
]
_append(filter_parts, _build_or_filters(METADATA_LIST, tag_attributes))
filter_str += _build_or_filters(METADATA_LIST, tag_attributes)
# Knowledge scope: explicit knowledge attachments (document_sets,
# user_file_ids) restrict what an assistant can see. When none are
# set, the assistant can see everything.
#
# project_id / persona_id are additive: they make overflowing user
# files findable in Vespa but must NOT trigger the restriction on
# their own (an agent with no explicit knowledge should search
# everything).
knowledge_scope_parts: list[str] = []
_append(
knowledge_scope_parts, _build_or_filters(DOCUMENT_SETS, filters.document_set)
)
# Document sets
filter_str += _build_or_filters(DOCUMENT_SETS, filters.document_set)
# Convert UUIDs to strings for user_file_ids
user_file_ids_str = (
[str(uuid) for uuid in filters.user_file_ids] if filters.user_file_ids else None
)
_append(knowledge_scope_parts, _build_or_filters(DOCUMENT_ID, user_file_ids_str))
filter_str += _build_or_filters(DOCUMENT_ID, user_file_ids_str)
# Only include project/persona scopes when an explicit knowledge
# restriction is already in effect — they widen the scope to also
# cover overflowing user files but never restrict on their own.
if knowledge_scope_parts:
_append(knowledge_scope_parts, _build_user_project_filter(filters.project_id))
_append(knowledge_scope_parts, _build_persona_filter(filters.persona_id))
# User project filter (array<int> attribute membership)
filter_str += _build_user_project_filter(filters.project_id)
if len(knowledge_scope_parts) > 1:
filter_parts.append("(" + " or ".join(knowledge_scope_parts) + ")")
elif len(knowledge_scope_parts) == 1:
filter_parts.append(knowledge_scope_parts[0])
# Persona filter (array<int> attribute membership)
filter_str += _build_persona_filter(filters.persona_id)
# Time filter
_append(filter_parts, _build_time_filter(filters.time_cutoff))
filter_str += _build_time_filter(filters.time_cutoff)
# # Knowledge Graph Filters
# _append(filter_parts, _build_kg_filter(
# filter_str += _build_kg_filter(
# kg_entities=filters.kg_entities,
# kg_relationships=filters.kg_relationships,
# kg_terms=filters.kg_terms,
# ))
# )
# _append(filter_parts, _build_kg_source_filters(filters.kg_sources))
# filter_str += _build_kg_source_filters(filters.kg_sources)
# _append(filter_parts, _build_kg_chunk_id_zero_only_filter(
# filter_str += _build_kg_chunk_id_zero_only_filter(
# filters.kg_chunk_id_zero_only or False
# ))
# )
filter_str = " and ".join(filter_parts)
if filter_str and not remove_trailing_and:
filter_str += " and "
# Trim trailing " and "
if remove_trailing_and and filter_str.endswith(" and "):
filter_str = filter_str[:-5]
return filter_str

View File

@@ -5,6 +5,7 @@ from datetime import datetime
from datetime import timedelta
from datetime import timezone
from typing import cast
from uuid import UUID
import jwt
from email_validator import EmailNotValidError
@@ -18,6 +19,7 @@ from fastapi import Query
from fastapi import Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.orm import Session
from onyx.auth.anonymous_user import fetch_anonymous_user_info
@@ -67,6 +69,7 @@ from onyx.db.user_preferences import update_user_role
from onyx.db.user_preferences import update_user_shortcut_enabled
from onyx.db.user_preferences import update_user_temperature_override_enabled
from onyx.db.user_preferences import update_user_theme_preference
from onyx.db.users import batch_get_user_groups
from onyx.db.users import delete_user_from_db
from onyx.db.users import get_all_users
from onyx.db.users import get_page_of_filtered_users
@@ -98,6 +101,7 @@ from onyx.server.manage.models import UserSpecificAssistantPreferences
from onyx.server.models import FullUserSnapshot
from onyx.server.models import InvitedUserSnapshot
from onyx.server.models import MinimalUserSnapshot
from onyx.server.models import UserGroupInfo
from onyx.server.usage_limits import is_tenant_on_trial_fn
from onyx.server.utils import BasicAuthenticationError
from onyx.utils.logger import setup_logger
@@ -203,9 +207,32 @@ def list_accepted_users(
total_items=0,
)
user_ids = [user.id for user in filtered_accepted_users]
groups_by_user = batch_get_user_groups(db_session, user_ids)
# Batch-fetch SCIM mappings to mark synced users
scim_synced_ids: set[UUID] = set()
try:
from onyx.db.models import ScimUserMapping
scim_mappings = db_session.scalars(
select(ScimUserMapping.user_id).where(ScimUserMapping.user_id.in_(user_ids))
).all()
scim_synced_ids = set(scim_mappings)
except Exception:
pass
return PaginatedReturn(
items=[
FullUserSnapshot.from_user_model(user) for user in filtered_accepted_users
FullUserSnapshot.from_user_model(
user,
groups=[
UserGroupInfo(id=gid, name=gname)
for gid, gname in groups_by_user.get(user.id, [])
],
is_scim_synced=user.id in scim_synced_ids,
)
for user in filtered_accepted_users
],
total_items=total_accepted_users_count,
)
@@ -269,24 +296,10 @@ def list_all_users(
if accepted_page is None or invited_page is None or slack_users_page is None:
return AllUsersResponse(
accepted=[
FullUserSnapshot(
id=user.id,
email=user.email,
role=user.role,
is_active=user.is_active,
password_configured=user.password_configured,
)
for user in accepted_users
FullUserSnapshot.from_user_model(user) for user in accepted_users
],
slack_users=[
FullUserSnapshot(
id=user.id,
email=user.email,
role=user.role,
is_active=user.is_active,
password_configured=user.password_configured,
)
for user in slack_users
FullUserSnapshot.from_user_model(user) for user in slack_users
],
invited=[InvitedUserSnapshot(email=email) for email in invited_emails],
accepted_pages=1,
@@ -296,26 +309,10 @@ def list_all_users(
# Otherwise, return paginated results
return AllUsersResponse(
accepted=[
FullUserSnapshot(
id=user.id,
email=user.email,
role=user.role,
is_active=user.is_active,
password_configured=user.password_configured,
)
for user in accepted_users
][accepted_page * USERS_PAGE_SIZE : (accepted_page + 1) * USERS_PAGE_SIZE],
slack_users=[
FullUserSnapshot(
id=user.id,
email=user.email,
role=user.role,
is_active=user.is_active,
password_configured=user.password_configured,
)
for user in slack_users
][
accepted=[FullUserSnapshot.from_user_model(user) for user in accepted_users][
accepted_page * USERS_PAGE_SIZE : (accepted_page + 1) * USERS_PAGE_SIZE
],
slack_users=[FullUserSnapshot.from_user_model(user) for user in slack_users][
slack_users_page
* USERS_PAGE_SIZE : (slack_users_page + 1)
* USERS_PAGE_SIZE

View File

@@ -1,3 +1,4 @@
import datetime
from typing import Generic
from typing import Optional
from typing import TypeVar
@@ -31,21 +32,41 @@ class MinimalUserSnapshot(BaseModel):
email: str
class UserGroupInfo(BaseModel):
id: int
name: str
class FullUserSnapshot(BaseModel):
id: UUID
email: str
role: UserRole
is_active: bool
password_configured: bool
personal_name: str | None
created_at: datetime.datetime
updated_at: datetime.datetime
groups: list[UserGroupInfo]
is_scim_synced: bool
@classmethod
def from_user_model(cls, user: User) -> "FullUserSnapshot":
def from_user_model(
cls,
user: User,
groups: list[UserGroupInfo] | None = None,
is_scim_synced: bool = False,
) -> "FullUserSnapshot":
return cls(
id=user.id,
email=user.email,
role=user.role,
is_active=user.is_active,
password_configured=user.password_configured,
personal_name=user.personal_name,
created_at=user.created_at,
updated_at=user.updated_at,
groups=groups or [],
is_scim_synced=is_scim_synced,
)

View File

@@ -1,17 +0,0 @@
"""
jsonriver - A streaming JSON parser for Python
Parse JSON incrementally as it streams in, e.g. from a network request or a language model.
Gives you a sequence of increasingly complete values.
Copyright (c) 2023 Google LLC (original TypeScript implementation)
Copyright (c) 2024 jsonriver-python contributors (Python port)
SPDX-License-Identifier: BSD-3-Clause
"""
from .parse import _Parser as Parser
from .parse import JsonObject
from .parse import JsonValue
__all__ = ["Parser", "JsonValue", "JsonObject"]
__version__ = "0.0.1"

View File

@@ -1,427 +0,0 @@
"""
JSON parser for streaming incremental parsing
Copyright (c) 2023 Google LLC (original TypeScript implementation)
Copyright (c) 2024 jsonriver-python contributors (Python port)
SPDX-License-Identifier: BSD-3-Clause
"""
from __future__ import annotations
import copy
from enum import IntEnum
from typing import cast
from typing import Union
from .tokenize import _Input
from .tokenize import json_token_type_to_string
from .tokenize import JsonTokenType
from .tokenize import Tokenizer
# Type definitions for JSON values
JsonValue = Union[None, bool, float, str, list["JsonValue"], dict[str, "JsonValue"]]
JsonObject = dict[str, JsonValue]
class _StateEnum(IntEnum):
"""Parser state machine states"""
Initial = 0
InString = 1
InArray = 2
InObjectExpectingKey = 3
InObjectExpectingValue = 4
class _State:
"""Base class for parser states"""
type: _StateEnum
value: JsonValue | tuple[str, JsonObject] | None
class _InitialState(_State):
"""Initial state before any parsing"""
def __init__(self) -> None:
self.type = _StateEnum.Initial
self.value = None
class _InStringState(_State):
"""State while parsing a string"""
def __init__(self) -> None:
self.type = _StateEnum.InString
self.value = ""
class _InArrayState(_State):
"""State while parsing an array"""
def __init__(self) -> None:
self.type = _StateEnum.InArray
self.value: list[JsonValue] = []
class _InObjectExpectingKeyState(_State):
"""State while parsing an object, expecting a key"""
def __init__(self) -> None:
self.type = _StateEnum.InObjectExpectingKey
self.value: JsonObject = {}
class _InObjectExpectingValueState(_State):
"""State while parsing an object, expecting a value"""
def __init__(self, key: str, obj: JsonObject) -> None:
self.type = _StateEnum.InObjectExpectingValue
self.value = (key, obj)
# Sentinel value to distinguish "not set" from "set to None/null"
class _Unset:
pass
_UNSET = _Unset()
class _Parser:
"""
Incremental JSON parser
Feed chunks of JSON text via feed() and get back progressively
more complete JSON values.
"""
def __init__(self) -> None:
self._state_stack: list[_State] = [_InitialState()]
self._toplevel_value: JsonValue | _Unset = _UNSET
self._input = _Input()
self.tokenizer = Tokenizer(self._input, self)
self._finished = False
self._progressed = False
self._prev_snapshot: JsonValue | _Unset = _UNSET
def feed(self, chunk: str) -> list[JsonValue]:
"""
Feed a chunk of JSON text and return deltas from the previous state.
Each element in the returned list represents what changed since the
last yielded value. For dicts, only changed/new keys are included,
with string values containing only the newly appended characters.
"""
if self._finished:
return []
self._input.feed(chunk)
return self._collect_deltas()
@staticmethod
def _compute_delta(prev: JsonValue | None, current: JsonValue) -> JsonValue | None:
if prev is None:
return current
if isinstance(current, dict) and isinstance(prev, dict):
result: JsonObject = {}
for key in current:
cur_val = current[key]
prev_val = prev.get(key)
if key not in prev:
result[key] = cur_val
elif isinstance(cur_val, str) and isinstance(prev_val, str):
if cur_val != prev_val:
result[key] = cur_val[len(prev_val) :]
elif isinstance(cur_val, list) and isinstance(prev_val, list):
if cur_val != prev_val:
new_items = cur_val[len(prev_val) :]
# check if the last existing element was updated
if (
prev_val
and len(cur_val) >= len(prev_val)
and cur_val[len(prev_val) - 1] != prev_val[-1]
):
result[key] = [cur_val[len(prev_val) - 1]] + new_items
elif new_items:
result[key] = new_items
elif cur_val != prev_val:
result[key] = cur_val
return result if result else None
if isinstance(current, str) and isinstance(prev, str):
delta = current[len(prev) :]
return delta if delta else None
if isinstance(current, list) and isinstance(prev, list):
if current != prev:
new_items = current[len(prev) :]
if (
prev
and len(current) >= len(prev)
and current[len(prev) - 1] != prev[-1]
):
return [current[len(prev) - 1]] + new_items
return new_items if new_items else None
return None
if current != prev:
return current
return None
def finish(self) -> list[JsonValue]:
"""Signal that no more chunks will be fed. Validates trailing content.
Returns any final deltas produced by flushing pending tokens (e.g.
numbers, which have no terminator and wait for more input).
"""
self._input.mark_complete()
# Pump once more so the tokenizer can emit tokens that were waiting
# for more input (e.g. numbers need buffer_complete to finalize).
results = self._collect_deltas()
self._input.expect_end_of_content()
return results
def _collect_deltas(self) -> list[JsonValue]:
"""Run one pump cycle and return any deltas produced."""
results: list[JsonValue] = []
while True:
self._progressed = False
self.tokenizer.pump()
if self._progressed:
if self._toplevel_value is _UNSET:
raise RuntimeError(
"Internal error: toplevel_value should not be unset "
"after progressing"
)
current = copy.deepcopy(cast(JsonValue, self._toplevel_value))
if isinstance(self._prev_snapshot, _Unset):
results.append(current)
else:
delta = self._compute_delta(self._prev_snapshot, current)
if delta is not None:
results.append(delta)
self._prev_snapshot = current
else:
if not self._state_stack:
self._finished = True
break
return results
# TokenHandler protocol implementation
def handle_null(self) -> None:
"""Handle null token"""
self._handle_value_token(JsonTokenType.Null, None)
def handle_boolean(self, value: bool) -> None:
"""Handle boolean token"""
self._handle_value_token(JsonTokenType.Boolean, value)
def handle_number(self, value: float) -> None:
"""Handle number token"""
self._handle_value_token(JsonTokenType.Number, value)
def handle_string_start(self) -> None:
"""Handle string start token"""
state = self._current_state()
if not self._progressed and state.type != _StateEnum.InObjectExpectingKey:
self._progressed = True
if state.type == _StateEnum.Initial:
self._state_stack.pop()
self._toplevel_value = self._progress_value(JsonTokenType.StringStart, None)
elif state.type == _StateEnum.InArray:
v = self._progress_value(JsonTokenType.StringStart, None)
arr = cast(list[JsonValue], state.value)
arr.append(v)
elif state.type == _StateEnum.InObjectExpectingKey:
self._state_stack.append(_InStringState())
elif state.type == _StateEnum.InObjectExpectingValue:
key, obj = cast(tuple[str, JsonObject], state.value)
sv = self._progress_value(JsonTokenType.StringStart, None)
obj[key] = sv
elif state.type == _StateEnum.InString:
raise ValueError(
f"Unexpected {json_token_type_to_string(JsonTokenType.StringStart)} "
f"token in the middle of string"
)
def handle_string_middle(self, value: str) -> None:
"""Handle string middle token"""
state = self._current_state()
if not self._progressed:
if len(self._state_stack) >= 2:
prev = self._state_stack[-2]
if prev.type != _StateEnum.InObjectExpectingKey:
self._progressed = True
else:
self._progressed = True
if state.type != _StateEnum.InString:
raise ValueError(
f"Unexpected {json_token_type_to_string(JsonTokenType.StringMiddle)} "
f"token when not in string"
)
assert isinstance(state.value, str)
state.value += value
parent_state = self._state_stack[-2] if len(self._state_stack) >= 2 else None
self._update_string_parent(state.value, parent_state)
def handle_string_end(self) -> None:
"""Handle string end token"""
state = self._current_state()
if state.type != _StateEnum.InString:
raise ValueError(
f"Unexpected {json_token_type_to_string(JsonTokenType.StringEnd)} "
f"token when not in string"
)
self._state_stack.pop()
parent_state = self._state_stack[-1] if self._state_stack else None
assert isinstance(state.value, str)
self._update_string_parent(state.value, parent_state)
def handle_array_start(self) -> None:
"""Handle array start token"""
self._handle_value_token(JsonTokenType.ArrayStart, None)
def handle_array_end(self) -> None:
"""Handle array end token"""
state = self._current_state()
if state.type != _StateEnum.InArray:
raise ValueError(
f"Unexpected {json_token_type_to_string(JsonTokenType.ArrayEnd)} token"
)
self._state_stack.pop()
def handle_object_start(self) -> None:
"""Handle object start token"""
self._handle_value_token(JsonTokenType.ObjectStart, None)
def handle_object_end(self) -> None:
"""Handle object end token"""
state = self._current_state()
if state.type in (
_StateEnum.InObjectExpectingKey,
_StateEnum.InObjectExpectingValue,
):
self._state_stack.pop()
else:
raise ValueError(
f"Unexpected {json_token_type_to_string(JsonTokenType.ObjectEnd)} token"
)
# Private helper methods
def _current_state(self) -> _State:
"""Get current parser state"""
if not self._state_stack:
raise ValueError("Unexpected trailing input")
return self._state_stack[-1]
def _handle_value_token(self, token_type: JsonTokenType, value: JsonValue) -> None:
"""Handle a complete value token"""
state = self._current_state()
if not self._progressed:
self._progressed = True
if state.type == _StateEnum.Initial:
self._state_stack.pop()
self._toplevel_value = self._progress_value(token_type, value)
elif state.type == _StateEnum.InArray:
v = self._progress_value(token_type, value)
arr = cast(list[JsonValue], state.value)
arr.append(v)
elif state.type == _StateEnum.InObjectExpectingValue:
key, obj = cast(tuple[str, JsonObject], state.value)
if token_type != JsonTokenType.StringStart:
self._state_stack.pop()
new_state = _InObjectExpectingKeyState()
new_state.value = obj
self._state_stack.append(new_state)
v = self._progress_value(token_type, value)
obj[key] = v
elif state.type == _StateEnum.InString:
raise ValueError(
f"Unexpected {json_token_type_to_string(token_type)} "
f"token in the middle of string"
)
elif state.type == _StateEnum.InObjectExpectingKey:
raise ValueError(
f"Unexpected {json_token_type_to_string(token_type)} "
f"token in the middle of object expecting key"
)
def _update_string_parent(self, updated: str, parent_state: _State | None) -> None:
"""Update parent container with updated string value"""
if parent_state is None:
self._toplevel_value = updated
elif parent_state.type == _StateEnum.InArray:
arr = cast(list[JsonValue], parent_state.value)
arr[-1] = updated
elif parent_state.type == _StateEnum.InObjectExpectingValue:
key, obj = cast(tuple[str, JsonObject], parent_state.value)
obj[key] = updated
if self._state_stack and self._state_stack[-1] == parent_state:
self._state_stack.pop()
new_state = _InObjectExpectingKeyState()
new_state.value = obj
self._state_stack.append(new_state)
elif parent_state.type == _StateEnum.InObjectExpectingKey:
if self._state_stack and self._state_stack[-1] == parent_state:
self._state_stack.pop()
obj = cast(JsonObject, parent_state.value)
self._state_stack.append(_InObjectExpectingValueState(updated, obj))
def _progress_value(self, token_type: JsonTokenType, value: JsonValue) -> JsonValue:
"""Create initial value for a token and push appropriate state"""
if token_type == JsonTokenType.Null:
return None
elif token_type == JsonTokenType.Boolean:
return value
elif token_type == JsonTokenType.Number:
return value
elif token_type == JsonTokenType.StringStart:
string_state = _InStringState()
self._state_stack.append(string_state)
return ""
elif token_type == JsonTokenType.ArrayStart:
array_state = _InArrayState()
self._state_stack.append(array_state)
return array_state.value
elif token_type == JsonTokenType.ObjectStart:
object_state = _InObjectExpectingKeyState()
self._state_stack.append(object_state)
return object_state.value
else:
raise ValueError(
f"Unexpected token type: {json_token_type_to_string(token_type)}"
)

View File

@@ -1,514 +0,0 @@
"""
JSON tokenizer for streaming incremental parsing
Copyright (c) 2023 Google LLC (original TypeScript implementation)
Copyright (c) 2024 jsonriver-python contributors (Python port)
SPDX-License-Identifier: BSD-3-Clause
"""
from __future__ import annotations
import re
from enum import IntEnum
from typing import Protocol
class TokenHandler(Protocol):
"""Protocol for handling JSON tokens"""
def handle_null(self) -> None: ...
def handle_boolean(self, value: bool) -> None: ...
def handle_number(self, value: float) -> None: ...
def handle_string_start(self) -> None: ...
def handle_string_middle(self, value: str) -> None: ...
def handle_string_end(self) -> None: ...
def handle_array_start(self) -> None: ...
def handle_array_end(self) -> None: ...
def handle_object_start(self) -> None: ...
def handle_object_end(self) -> None: ...
class JsonTokenType(IntEnum):
"""Types of JSON tokens"""
Null = 0
Boolean = 1
Number = 2
StringStart = 3
StringMiddle = 4
StringEnd = 5
ArrayStart = 6
ArrayEnd = 7
ObjectStart = 8
ObjectEnd = 9
def json_token_type_to_string(token_type: JsonTokenType) -> str:
"""Convert token type to readable string"""
names = {
JsonTokenType.Null: "null",
JsonTokenType.Boolean: "boolean",
JsonTokenType.Number: "number",
JsonTokenType.StringStart: "string start",
JsonTokenType.StringMiddle: "string middle",
JsonTokenType.StringEnd: "string end",
JsonTokenType.ArrayStart: "array start",
JsonTokenType.ArrayEnd: "array end",
JsonTokenType.ObjectStart: "object start",
JsonTokenType.ObjectEnd: "object end",
}
return names[token_type]
class _State(IntEnum):
"""Internal tokenizer states"""
ExpectingValue = 0
InString = 1
StartArray = 2
AfterArrayValue = 3
StartObject = 4
AfterObjectKey = 5
AfterObjectValue = 6
BeforeObjectKey = 7
# Regex for validating JSON numbers
_JSON_NUMBER_PATTERN = re.compile(r"^-?(0|[1-9]\d*)(\.\d+)?([eE][+-]?\d+)?$")
def _parse_json_number(s: str) -> float:
"""Parse a JSON number string, validating format"""
if not _JSON_NUMBER_PATTERN.match(s):
raise ValueError("Invalid number")
return float(s)
class _Input:
"""
Input buffer for chunk-based JSON parsing
Manages buffering of input chunks and provides methods for
consuming and inspecting the buffer.
"""
def __init__(self) -> None:
self._buffer = ""
self._start_index = 0
self.buffer_complete = False
def feed(self, chunk: str) -> None:
"""Add a chunk of data to the buffer"""
self._buffer += chunk
def mark_complete(self) -> None:
"""Signal that no more chunks will be fed"""
self.buffer_complete = True
@property
def length(self) -> int:
"""Number of characters remaining in buffer"""
return len(self._buffer) - self._start_index
def advance(self, length: int) -> None:
"""Advance the start position by length characters"""
self._start_index += length
def peek(self, offset: int) -> str | None:
"""Peek at character at offset, or None if not available"""
idx = self._start_index + offset
if idx < len(self._buffer):
return self._buffer[idx]
return None
def peek_char_code(self, offset: int) -> int:
"""Get character code at offset"""
return ord(self._buffer[self._start_index + offset])
def slice(self, start: int, end: int) -> str:
"""Slice buffer from start to end (relative to current position)"""
return self._buffer[self._start_index + start : self._start_index + end]
def commit(self) -> None:
"""Commit consumed content, removing it from buffer"""
if self._start_index > 0:
self._buffer = self._buffer[self._start_index :]
self._start_index = 0
def remaining(self) -> str:
"""Get all remaining content in buffer"""
return self._buffer[self._start_index :]
def expect_end_of_content(self) -> None:
"""Verify no non-whitespace content remains"""
self.commit()
self.skip_past_whitespace()
if self.length != 0:
raise ValueError(f"Unexpected trailing content {self.remaining()!r}")
def skip_past_whitespace(self) -> None:
"""Skip whitespace characters"""
i = self._start_index
while i < len(self._buffer):
c = ord(self._buffer[i])
if c in (32, 9, 10, 13): # space, tab, \n, \r
i += 1
else:
break
self._start_index = i
def try_to_take_prefix(self, prefix: str) -> bool:
"""Try to consume prefix from buffer, return True if successful"""
if self._buffer.startswith(prefix, self._start_index):
self._start_index += len(prefix)
return True
return False
def try_to_take(self, length: int) -> str | None:
"""Try to take length characters, or None if not enough available"""
if self.length < length:
return None
result = self._buffer[self._start_index : self._start_index + length]
self._start_index += length
return result
def try_to_take_char_code(self) -> int | None:
"""Try to take a single character as char code, or None if buffer empty"""
if self.length == 0:
return None
code = ord(self._buffer[self._start_index])
self._start_index += 1
return code
def take_until_quote_or_backslash(self) -> tuple[str, bool]:
"""
Consume input up to first quote or backslash
Returns tuple of (consumed_content, pattern_found)
"""
buf = self._buffer
i = self._start_index
while i < len(buf):
c = ord(buf[i])
if c <= 0x1F:
raise ValueError("Unescaped control character in string")
if c == 34 or c == 92: # " or \
result = buf[self._start_index : i]
self._start_index = i
return (result, True)
i += 1
result = buf[self._start_index :]
self._start_index = len(buf)
return (result, False)
class Tokenizer:
"""
Tokenizer for chunk-based JSON parsing
Processes chunks fed into its input buffer and calls handler methods
as JSON tokens are recognized.
"""
def __init__(self, input: _Input, handler: TokenHandler) -> None:
self.input = input
self._handler = handler
self._stack: list[_State] = [_State.ExpectingValue]
self._emitted_tokens = 0
def is_done(self) -> bool:
"""Check if tokenization is complete"""
return len(self._stack) == 0 and self.input.length == 0
def pump(self) -> None:
"""Process all available tokens in the buffer"""
while True:
before = self._emitted_tokens
self._tokenize_more()
if self._emitted_tokens == before:
self.input.commit()
return
def _tokenize_more(self) -> None:
"""Process one step of tokenization based on current state"""
if not self._stack:
return
state = self._stack[-1]
if state == _State.ExpectingValue:
self._tokenize_value()
elif state == _State.InString:
self._tokenize_string()
elif state == _State.StartArray:
self._tokenize_array_start()
elif state == _State.AfterArrayValue:
self._tokenize_after_array_value()
elif state == _State.StartObject:
self._tokenize_object_start()
elif state == _State.AfterObjectKey:
self._tokenize_after_object_key()
elif state == _State.AfterObjectValue:
self._tokenize_after_object_value()
elif state == _State.BeforeObjectKey:
self._tokenize_before_object_key()
def _tokenize_value(self) -> None:
"""Tokenize a JSON value"""
self.input.skip_past_whitespace()
if self.input.try_to_take_prefix("null"):
self._handler.handle_null()
self._emitted_tokens += 1
self._stack.pop()
return
if self.input.try_to_take_prefix("true"):
self._handler.handle_boolean(True)
self._emitted_tokens += 1
self._stack.pop()
return
if self.input.try_to_take_prefix("false"):
self._handler.handle_boolean(False)
self._emitted_tokens += 1
self._stack.pop()
return
if self.input.length > 0:
ch = self.input.peek_char_code(0)
if (48 <= ch <= 57) or ch == 45: # 0-9 or -
# Scan for end of number
i = 0
while i < self.input.length:
c = self.input.peek_char_code(i)
if (48 <= c <= 57) or c in (45, 43, 46, 101, 69): # 0-9 - + . e E
i += 1
else:
break
if i == self.input.length and not self.input.buffer_complete:
# Need more input (numbers have no terminator)
return
number_chars = self.input.slice(0, i)
self.input.advance(i)
number = _parse_json_number(number_chars)
self._handler.handle_number(number)
self._emitted_tokens += 1
self._stack.pop()
return
if self.input.try_to_take_prefix('"'):
self._stack.pop()
self._stack.append(_State.InString)
self._handler.handle_string_start()
self._emitted_tokens += 1
self._tokenize_string()
return
if self.input.try_to_take_prefix("["):
self._stack.pop()
self._stack.append(_State.StartArray)
self._handler.handle_array_start()
self._emitted_tokens += 1
self._tokenize_array_start()
return
if self.input.try_to_take_prefix("{"):
self._stack.pop()
self._stack.append(_State.StartObject)
self._handler.handle_object_start()
self._emitted_tokens += 1
self._tokenize_object_start()
return
def _tokenize_string(self) -> None:
"""Tokenize string content"""
while True:
chunk, interrupted = self.input.take_until_quote_or_backslash()
if chunk:
self._handler.handle_string_middle(chunk)
self._emitted_tokens += 1
elif not interrupted:
return
if interrupted:
if self.input.length == 0:
return
next_char = self.input.peek(0)
if next_char == '"':
self.input.advance(1)
self._handler.handle_string_end()
self._emitted_tokens += 1
self._stack.pop()
return
# Handle escape sequences
next_char2 = self.input.peek(1)
if next_char2 is None:
return
value: str
if next_char2 == "u":
# Unicode escape: need 4 hex digits
if self.input.length < 6:
return
code = 0
for j in range(2, 6):
c = self.input.peek_char_code(j)
if 48 <= c <= 57: # 0-9
digit = c - 48
elif 65 <= c <= 70: # A-F
digit = c - 55
elif 97 <= c <= 102: # a-f
digit = c - 87
else:
raise ValueError("Bad Unicode escape in JSON")
code = (code << 4) | digit
self.input.advance(6)
self._handler.handle_string_middle(chr(code))
self._emitted_tokens += 1
continue
elif next_char2 == "n":
value = "\n"
elif next_char2 == "r":
value = "\r"
elif next_char2 == "t":
value = "\t"
elif next_char2 == "b":
value = "\b"
elif next_char2 == "f":
value = "\f"
elif next_char2 == "\\":
value = "\\"
elif next_char2 == "/":
value = "/"
elif next_char2 == '"':
value = '"'
else:
raise ValueError("Bad escape in string")
self.input.advance(2)
self._handler.handle_string_middle(value)
self._emitted_tokens += 1
def _tokenize_array_start(self) -> None:
"""Tokenize start of array (check for empty or first element)"""
self.input.skip_past_whitespace()
if self.input.length == 0:
return
if self.input.try_to_take_prefix("]"):
self._handler.handle_array_end()
self._emitted_tokens += 1
self._stack.pop()
return
self._stack.pop()
self._stack.append(_State.AfterArrayValue)
self._stack.append(_State.ExpectingValue)
self._tokenize_value()
def _tokenize_after_array_value(self) -> None:
"""Tokenize after an array value (expect , or ])"""
self.input.skip_past_whitespace()
next_char = self.input.try_to_take_char_code()
if next_char is None:
return
elif next_char == 0x5D: # ]
self._handler.handle_array_end()
self._emitted_tokens += 1
self._stack.pop()
return
elif next_char == 0x2C: # ,
self._stack.append(_State.ExpectingValue)
self._tokenize_value()
return
else:
raise ValueError(f"Expected , or ], got {chr(next_char)!r}")
def _tokenize_object_start(self) -> None:
"""Tokenize start of object (check for empty or first key)"""
self.input.skip_past_whitespace()
next_char = self.input.try_to_take_char_code()
if next_char is None:
return
elif next_char == 0x7D: # }
self._handler.handle_object_end()
self._emitted_tokens += 1
self._stack.pop()
return
elif next_char == 0x22: # "
self._stack.pop()
self._stack.append(_State.AfterObjectKey)
self._stack.append(_State.InString)
self._handler.handle_string_start()
self._emitted_tokens += 1
self._tokenize_string()
return
else:
raise ValueError(f"Expected start of object key, got {chr(next_char)!r}")
def _tokenize_after_object_key(self) -> None:
"""Tokenize after object key (expect :)"""
self.input.skip_past_whitespace()
next_char = self.input.try_to_take_char_code()
if next_char is None:
return
elif next_char == 0x3A: # :
self._stack.pop()
self._stack.append(_State.AfterObjectValue)
self._stack.append(_State.ExpectingValue)
self._tokenize_value()
return
else:
raise ValueError(f"Expected colon after object key, got {chr(next_char)!r}")
def _tokenize_after_object_value(self) -> None:
"""Tokenize after object value (expect , or })"""
self.input.skip_past_whitespace()
next_char = self.input.try_to_take_char_code()
if next_char is None:
return
elif next_char == 0x7D: # }
self._handler.handle_object_end()
self._emitted_tokens += 1
self._stack.pop()
return
elif next_char == 0x2C: # ,
self._stack.pop()
self._stack.append(_State.BeforeObjectKey)
self._tokenize_before_object_key()
return
else:
raise ValueError(
f"Expected , or }} after object value, got {chr(next_char)!r}"
)
def _tokenize_before_object_key(self) -> None:
"""Tokenize before object key (after comma)"""
self.input.skip_past_whitespace()
next_char = self.input.try_to_take_char_code()
if next_char is None:
return
elif next_char == 0x22: # "
self._stack.pop()
self._stack.append(_State.AfterObjectKey)
self._stack.append(_State.InString)
self._handler.handle_string_start()
self._emitted_tokens += 1
self._tokenize_string()
return
else:
raise ValueError(f"Expected start of object key, got {chr(next_char)!r}")

View File

@@ -1,394 +0,0 @@
"""Tests for the jsonriver incremental JSON parser."""
import json
import pytest
from onyx.utils.jsonriver import JsonValue
from onyx.utils.jsonriver import Parser
def _all_deltas(chunks: list[str]) -> list[JsonValue]:
"""Feed chunks one at a time and collect all emitted deltas."""
parser = Parser()
deltas: list[JsonValue] = []
for chunk in chunks:
deltas.extend(parser.feed(chunk))
deltas.extend(parser.finish())
return deltas
class TestParseComplete:
"""Parsing complete JSON in a single chunk."""
def test_simple_object(self) -> None:
deltas = _all_deltas(['{"a": 1}'])
assert any(r == {"a": 1.0} or r == {"a": 1} for r in deltas)
def test_simple_array(self) -> None:
deltas = _all_deltas(["[1, 2, 3]"])
assert any(isinstance(r, list) for r in deltas)
def test_simple_string(self) -> None:
deltas = _all_deltas(['"hello"'])
assert "hello" in deltas or any("hello" in str(r) for r in deltas)
def test_null(self) -> None:
deltas = _all_deltas(["null"])
assert None in deltas
def test_boolean_true(self) -> None:
deltas = _all_deltas(["true"])
assert True in deltas
def test_boolean_false(self) -> None:
deltas = _all_deltas(["false"])
assert any(r is False for r in deltas)
def test_number(self) -> None:
deltas = _all_deltas(["42"])
assert 42.0 in deltas
def test_negative_number(self) -> None:
deltas = _all_deltas(["-3.14"])
assert any(abs(r - (-3.14)) < 1e-10 for r in deltas if isinstance(r, float))
def test_empty_object(self) -> None:
deltas = _all_deltas(["{}"])
assert {} in deltas
def test_empty_array(self) -> None:
deltas = _all_deltas(["[]"])
assert [] in deltas
class TestStreamingDeltas:
"""Incremental feeding produces correct deltas."""
def test_object_string_value_streamed_char_by_char(self) -> None:
chunks = list('{"code": "abc"}')
deltas = _all_deltas(chunks)
str_parts = []
for d in deltas:
if isinstance(d, dict) and "code" in d:
val = d["code"]
if isinstance(val, str):
str_parts.append(val)
assert "".join(str_parts) == "abc"
def test_object_streamed_in_two_halves(self) -> None:
deltas = _all_deltas(['{"name": "Al', 'ice"}'])
str_parts = []
for d in deltas:
if isinstance(d, dict) and "name" in d:
val = d["name"]
if isinstance(val, str):
str_parts.append(val)
assert "".join(str_parts) == "Alice"
def test_multiple_keys_streamed(self) -> None:
deltas = _all_deltas(['{"a": "x', '", "b": "y"}'])
a_parts: list[str] = []
b_parts: list[str] = []
for d in deltas:
if isinstance(d, dict):
if "a" in d and isinstance(d["a"], str):
a_parts.append(d["a"])
if "b" in d and isinstance(d["b"], str):
b_parts.append(d["b"])
assert "".join(a_parts) == "x"
assert "".join(b_parts) == "y"
def test_deltas_only_contain_new_string_content(self) -> None:
parser = Parser()
d1 = parser.feed('{"msg": "hel')
d2 = parser.feed('lo"}')
parser.finish()
msg_parts = []
for d in d1 + d2:
if isinstance(d, dict) and "msg" in d:
val = d["msg"]
if isinstance(val, str):
msg_parts.append(val)
assert "".join(msg_parts) == "hello"
# Each delta should only contain new chars, not repeat previous ones
if len(msg_parts) == 2:
assert msg_parts[0] == "hel"
assert msg_parts[1] == "lo"
class TestEscapeSequences:
"""JSON escape sequences are decoded correctly, even across chunk boundaries."""
def test_newline_escape(self) -> None:
deltas = _all_deltas(['{"text": "line1\\nline2"}'])
text_parts = []
for d in deltas:
if isinstance(d, dict) and "text" in d and isinstance(d["text"], str):
text_parts.append(d["text"])
assert "".join(text_parts) == "line1\nline2"
def test_tab_escape(self) -> None:
deltas = _all_deltas(['{"t": "a\\tb"}'])
parts = []
for d in deltas:
if isinstance(d, dict) and "t" in d and isinstance(d["t"], str):
parts.append(d["t"])
assert "".join(parts) == "a\tb"
def test_escaped_quote(self) -> None:
deltas = _all_deltas(['{"q": "say \\"hi\\""}'])
parts = []
for d in deltas:
if isinstance(d, dict) and "q" in d and isinstance(d["q"], str):
parts.append(d["q"])
assert "".join(parts) == 'say "hi"'
def test_unicode_escape(self) -> None:
deltas = _all_deltas(['{"u": "\\u0041\\u0042"}'])
parts = []
for d in deltas:
if isinstance(d, dict) and "u" in d and isinstance(d["u"], str):
parts.append(d["u"])
assert "".join(parts) == "AB"
def test_escape_split_across_chunks(self) -> None:
deltas = _all_deltas(['{"x": "a\\', 'nb"}'])
parts = []
for d in deltas:
if isinstance(d, dict) and "x" in d and isinstance(d["x"], str):
parts.append(d["x"])
assert "".join(parts) == "a\nb"
def test_unicode_escape_split_across_chunks(self) -> None:
deltas = _all_deltas(['{"u": "\\u00', '41"}'])
parts = []
for d in deltas:
if isinstance(d, dict) and "u" in d and isinstance(d["u"], str):
parts.append(d["u"])
assert "".join(parts) == "A"
def test_backslash_escape(self) -> None:
deltas = _all_deltas(['{"p": "c:\\\\dir"}'])
parts = []
for d in deltas:
if isinstance(d, dict) and "p" in d and isinstance(d["p"], str):
parts.append(d["p"])
assert "".join(parts) == "c:\\dir"
class TestNestedStructures:
"""Nested objects and arrays produce correct deltas."""
def test_nested_object(self) -> None:
deltas = _all_deltas(['{"outer": {"inner": "val"}}'])
found = False
for d in deltas:
if isinstance(d, dict) and "outer" in d:
outer = d["outer"]
if isinstance(outer, dict) and "inner" in outer:
found = True
assert found
def test_array_of_strings(self) -> None:
deltas = _all_deltas(['["a', '", "b"]'])
all_items: list[str] = []
for d in deltas:
if isinstance(d, list):
for item in d:
if isinstance(item, str):
all_items.append(item)
elif isinstance(d, str):
all_items.append(d)
joined = "".join(all_items)
assert "a" in joined
assert "b" in joined
def test_object_with_number_and_bool(self) -> None:
deltas = _all_deltas(['{"count": 42, "active": true}'])
has_count = False
has_active = False
for d in deltas:
if isinstance(d, dict):
if "count" in d and d["count"] == 42.0:
has_count = True
if "active" in d and d["active"] is True:
has_active = True
assert has_count
assert has_active
def test_object_with_null_value(self) -> None:
deltas = _all_deltas(['{"key": null}'])
found = False
for d in deltas:
if isinstance(d, dict) and "key" in d and d["key"] is None:
found = True
assert found
class TestComputeDelta:
"""Direct tests for the _compute_delta static method."""
def test_none_prev_returns_current(self) -> None:
assert Parser._compute_delta(None, {"a": "b"}) == {"a": "b"}
def test_string_delta(self) -> None:
assert Parser._compute_delta("hel", "hello") == "lo"
def test_string_no_change(self) -> None:
assert Parser._compute_delta("same", "same") is None
def test_dict_new_key(self) -> None:
assert Parser._compute_delta({"a": "x"}, {"a": "x", "b": "y"}) == {"b": "y"}
def test_dict_string_append(self) -> None:
assert Parser._compute_delta({"code": "def"}, {"code": "def hello()"}) == {
"code": " hello()"
}
def test_dict_no_change(self) -> None:
assert Parser._compute_delta({"a": 1}, {"a": 1}) is None
def test_list_new_items(self) -> None:
assert Parser._compute_delta([1, 2], [1, 2, 3]) == [3]
def test_list_last_item_updated(self) -> None:
assert Parser._compute_delta(["a"], ["ab"]) == ["ab"]
def test_list_no_change(self) -> None:
assert Parser._compute_delta([1, 2], [1, 2]) is None
def test_primitive_change(self) -> None:
assert Parser._compute_delta(1, 2) == 2
def test_primitive_no_change(self) -> None:
assert Parser._compute_delta(42, 42) is None
class TestParserLifecycle:
"""Edge cases around parser state and lifecycle."""
def test_feed_after_finish_returns_empty(self) -> None:
parser = Parser()
parser.feed('{"a": 1}')
parser.finish()
assert parser.feed("more") == []
def test_empty_feed_returns_empty(self) -> None:
parser = Parser()
assert parser.feed("") == []
def test_whitespace_only_returns_empty(self) -> None:
parser = Parser()
assert parser.feed(" ") == []
def test_finish_with_trailing_whitespace(self) -> None:
parser = Parser()
# Trailing whitespace terminates the number, so feed() emits it
deltas = parser.feed("42 ")
assert 42.0 in deltas
parser.finish() # Should not raise
def test_finish_with_trailing_content_raises(self) -> None:
parser = Parser()
# Feed a complete JSON value followed by non-whitespace in one chunk
parser.feed('{"a": 1} extra')
with pytest.raises(ValueError, match="Unexpected trailing"):
parser.finish()
def test_finish_flushes_pending_number(self) -> None:
parser = Parser()
deltas = parser.feed("42")
# Number has no terminator, so feed() can't emit it yet
assert deltas == []
final = parser.finish()
assert 42.0 in final
class TestToolCallSimulation:
"""Simulate the LLM tool-call streaming use case."""
def test_python_tool_call_streaming(self) -> None:
full_json = json.dumps({"code": "print('hello world')"})
chunk_size = 5
chunks = [
full_json[i : i + chunk_size] for i in range(0, len(full_json), chunk_size)
]
parser = Parser()
code_parts: list[str] = []
for chunk in chunks:
for delta in parser.feed(chunk):
if isinstance(delta, dict) and "code" in delta:
val = delta["code"]
if isinstance(val, str):
code_parts.append(val)
for delta in parser.finish():
if isinstance(delta, dict) and "code" in delta:
val = delta["code"]
if isinstance(val, str):
code_parts.append(val)
assert "".join(code_parts) == "print('hello world')"
def test_multi_arg_tool_call(self) -> None:
full = '{"query": "search term", "num_results": 5}'
chunks = [full[:15], full[15:30], full[30:]]
parser = Parser()
query_parts: list[str] = []
has_num_results = False
for chunk in chunks:
for delta in parser.feed(chunk):
if isinstance(delta, dict):
if "query" in delta and isinstance(delta["query"], str):
query_parts.append(delta["query"])
if "num_results" in delta:
has_num_results = True
for delta in parser.finish():
if isinstance(delta, dict):
if "query" in delta and isinstance(delta["query"], str):
query_parts.append(delta["query"])
if "num_results" in delta:
has_num_results = True
assert "".join(query_parts) == "search term"
assert has_num_results
def test_code_with_newlines_and_escapes(self) -> None:
code = 'def greet(name):\n print(f"Hello, {name}!")\n return True'
full = json.dumps({"code": code})
chunk_size = 8
chunks = [full[i : i + chunk_size] for i in range(0, len(full), chunk_size)]
parser = Parser()
code_parts: list[str] = []
for chunk in chunks:
for delta in parser.feed(chunk):
if isinstance(delta, dict) and "code" in delta:
val = delta["code"]
if isinstance(val, str):
code_parts.append(val)
for delta in parser.finish():
if isinstance(delta, dict) and "code" in delta:
val = delta["code"]
if isinstance(val, str):
code_parts.append(val)
assert "".join(code_parts) == code
def test_single_char_streaming(self) -> None:
full = '{"key": "value"}'
parser = Parser()
key_parts: list[str] = []
for ch in full:
for delta in parser.feed(ch):
if isinstance(delta, dict) and "key" in delta:
val = delta["key"]
if isinstance(val, str):
key_parts.append(val)
for delta in parser.finish():
if isinstance(delta, dict) and "key" in delta:
val = delta["key"]
if isinstance(val, str):
key_parts.append(val)
assert "".join(key_parts) == "value"

View File

@@ -20,6 +20,8 @@ from onyx.document_index.vespa_constants import TENANT_ID
from onyx.document_index.vespa_constants import USER_PROJECT
from shared_configs.configs import MULTI_TENANT
# Import the function under test
class TestBuildVespaFilters:
def test_empty_filters(self) -> None:
@@ -177,27 +179,11 @@ class TestBuildVespaFilters:
assert f"!({HIDDEN}=true) and " == result
def test_user_project_filter(self) -> None:
"""Test user project filtering.
project_id alone does NOT trigger a knowledge scope restriction
(an agent with no explicit knowledge should search everything).
It only participates when explicit knowledge filters are present.
"""
# project_id alone → no restriction
"""Test user project filtering (replacement for user folder IDs)."""
# Single project id
filters = IndexFilters(access_control_list=[], project_id=789)
result = build_vespa_filters(filters)
assert f"!({HIDDEN}=true) and " == result
# project_id with user_file_ids → both OR'd
id1 = UUID("00000000-0000-0000-0000-000000000123")
filters = IndexFilters(
access_control_list=[], project_id=789, user_file_ids=[id1]
)
result = build_vespa_filters(filters)
assert (
f'!({HIDDEN}=true) and (({DOCUMENT_ID} contains "{str(id1)}") or ({USER_PROJECT} contains "789")) and '
== result
)
assert f'!({HIDDEN}=true) and ({USER_PROJECT} contains "789") and ' == result
# No project id
filters = IndexFilters(access_control_list=[], project_id=None)
@@ -231,11 +217,7 @@ class TestBuildVespaFilters:
)
def test_combined_filters(self) -> None:
"""Test combining multiple filter types.
Knowledge-scope filters (document_set, user_file_ids, project_id,
persona_id) are OR'd together, while all other filters are AND'd.
"""
"""Test combining multiple filter types."""
id1 = UUID("00000000-0000-0000-0000-000000000123")
filters = IndexFilters(
access_control_list=["user1", "group1"],
@@ -249,6 +231,7 @@ class TestBuildVespaFilters:
result = build_vespa_filters(filters)
# Build expected result piece by piece for readability
expected = f"!({HIDDEN}=true) and "
expected += (
'(access_control_list contains "user1" or '
@@ -256,13 +239,9 @@ class TestBuildVespaFilters:
)
expected += f'({SOURCE_TYPE} contains "web") and '
expected += f'({METADATA_LIST} contains "color{INDEX_SEPARATOR}red") and '
# Knowledge scope filters are OR'd together
expected += (
f'(({DOCUMENT_SETS} contains "set1")'
f' or ({DOCUMENT_ID} contains "{str(id1)}")'
f' or ({USER_PROJECT} contains "789")'
f") and "
)
expected += f'({DOCUMENT_SETS} contains "set1") and '
expected += f'({DOCUMENT_ID} contains "{str(id1)}") and '
expected += f'({USER_PROJECT} contains "789") and '
cutoff_secs = int(datetime(2023, 1, 1, tzinfo=timezone.utc).timestamp())
expected += f"!({DOC_UPDATED_AT} < {cutoff_secs}) and "
@@ -272,32 +251,6 @@ class TestBuildVespaFilters:
result_no_trailing = build_vespa_filters(filters, remove_trailing_and=True)
assert expected[:-5] == result_no_trailing # Remove trailing " and "
def test_knowledge_scope_single_filter_not_wrapped(self) -> None:
"""When only one knowledge-scope filter is present it should not
be wrapped in an extra OR group."""
filters = IndexFilters(access_control_list=[], document_set=["set1"])
result = build_vespa_filters(filters)
assert f'!({HIDDEN}=true) and ({DOCUMENT_SETS} contains "set1") and ' == result
def test_knowledge_scope_document_set_and_user_files_ored(self) -> None:
"""Document set filter and user file IDs must be OR'd so that
connector documents (in the set) and user files (with specific
IDs) can both be found."""
id1 = UUID("00000000-0000-0000-0000-000000000123")
filters = IndexFilters(
access_control_list=[],
document_set=["engineering"],
user_file_ids=[id1],
)
result = build_vespa_filters(filters)
expected = (
f"!({HIDDEN}=true) and "
f'(({DOCUMENT_SETS} contains "engineering")'
f' or ({DOCUMENT_ID} contains "{str(id1)}")'
f") and "
)
assert expected == result
def test_empty_or_none_values(self) -> None:
"""Test with empty or None values in filter lists."""
# Empty strings in document set

View File

@@ -1,8 +1,5 @@
# Onyx CLI
[![Release CLI](https://github.com/onyx-dot-app/onyx/actions/workflows/release-cli.yml/badge.svg)](https://github.com/onyx-dot-app/onyx/actions/workflows/release-cli.yml)
[![PyPI](https://img.shields.io/pypi/v/onyx-cli.svg)](https://pypi.org/project/onyx-cli/)
A terminal interface for chatting with your [Onyx](https://github.com/onyx-dot-app/onyx) agent. Built with Go using [Bubble Tea](https://github.com/charmbracelet/bubbletea) for the TUI framework.
## Installation
@@ -31,7 +28,7 @@ Environment variables override config file values:
| Variable | Required | Description |
|----------|----------|-------------|
| `ONYX_SERVER_URL` | No | Server base URL (default: `https://cloud.onyx.app`) |
| `ONYX_SERVER_URL` | No | Server base URL (default: `http://localhost:3000`) |
| `ONYX_API_KEY` | Yes | API key for authentication |
| `ONYX_PERSONA_ID` | No | Default agent/persona ID |
@@ -63,31 +60,6 @@ onyx-cli agents
onyx-cli agents --json
```
### Serve over SSH
```shell
# Start a public SSH endpoint for the CLI TUI
onyx-cli serve --host 0.0.0.0 --port 2222
# Connect as a client
ssh your-host -p 2222
```
Clients can either:
- paste an API key at the login prompt, or
- skip the prompt by sending `ONYX_API_KEY` over SSH:
```shell
export ONYX_API_KEY=your-key
ssh -o SendEnv=ONYX_API_KEY your-host -p 2222
```
Useful hardening flags:
- `--idle-timeout` (default `15m`)
- `--max-session-timeout` (default `8h`)
- `--rate-limit-per-minute` (default `20`)
- `--rate-limit-burst` (default `40`)
## Commands
| Command | Description |
@@ -95,7 +67,6 @@ Useful hardening flags:
| `chat` | Launch the interactive chat TUI (default) |
| `ask` | Ask a one-shot question (non-interactive) |
| `agents` | List available agents |
| `serve` | Serve the interactive chat TUI over SSH |
| `configure` | Configure server URL and API key |
| `validate-config` | Validate configuration and test connection |

View File

@@ -32,7 +32,6 @@ func Execute() error {
rootCmd.AddCommand(newAgentsCmd())
rootCmd.AddCommand(newConfigureCmd())
rootCmd.AddCommand(newValidateConfigCmd())
rootCmd.AddCommand(newServeCmd())
// Default command is chat
rootCmd.RunE = chatCmd.RunE

View File

@@ -1,401 +0,0 @@
package cmd
import (
"context"
"errors"
"fmt"
"io"
"net"
"os"
"os/signal"
"strings"
"sync"
"syscall"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/log"
"github.com/charmbracelet/ssh"
"github.com/charmbracelet/wish"
"github.com/charmbracelet/wish/activeterm"
"github.com/charmbracelet/wish/bubbletea"
"github.com/charmbracelet/wish/logging"
"github.com/charmbracelet/wish/ratelimiter"
"github.com/onyx-dot-app/onyx/cli/internal/api"
"github.com/onyx-dot-app/onyx/cli/internal/config"
"github.com/onyx-dot-app/onyx/cli/internal/tui"
"github.com/spf13/cobra"
"golang.org/x/time/rate"
)
var sessionAPIKeys sync.Map
const (
defaultServeIdleTimeout = 15 * time.Minute
defaultServeMaxSessionTimeout = 8 * time.Hour
defaultServeRateLimitPerMinute = 20
defaultServeRateLimitBurst = 40
defaultServeRateLimitCacheSize = 4096
maxAPIKeyLength = 256
apiKeyValidationTimeout = 15 * time.Second
)
var errAPIKeyTooLong = fmt.Errorf("API key is too long (max %d characters)", maxAPIKeyLength)
// sshWriter wraps an io.Writer and tracks the first write error, allowing
// a sequence of writes to be checked once at the end.
type sshWriter struct {
w io.Writer
err error
}
func (w *sshWriter) print(s string) {
if w.err == nil {
_, w.err = fmt.Fprint(w.w, s)
}
}
func (w *sshWriter) printf(format string, args ...any) {
if w.err == nil {
_, w.err = fmt.Fprintf(w.w, format, args...)
}
}
func sessionEnv(s ssh.Session, key string) string {
prefix := key + "="
for _, env := range s.Environ() {
if strings.HasPrefix(env, prefix) {
return env[len(prefix):]
}
}
return ""
}
func validateAPIKey(serverURL string, apiKey string) error {
trimmedKey := strings.TrimSpace(apiKey)
if len(trimmedKey) > maxAPIKeyLength {
return errAPIKeyTooLong
}
cfg := config.OnyxCliConfig{
ServerURL: serverURL,
APIKey: trimmedKey,
}
client := api.NewClient(cfg)
ctx, cancel := context.WithTimeout(context.Background(), apiKeyValidationTimeout)
defer cancel()
return client.TestConnection(ctx)
}
// readMasked reads input from the SSH session one chunk at a time, echoing '•'
// for each printable character. Handles backspace and skips escape sequences.
func readMasked(w *sshWriter, s ssh.Session) (string, error) {
var key []byte
buf := make([]byte, 4096)
inEscape := false
for {
n, err := s.Read(buf)
if err != nil {
return "", err
}
for i := 0; i < n; i++ {
b := buf[i]
if inEscape {
if (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z') || b == '~' {
inEscape = false
}
continue
}
switch {
case b == 27: // ESC
inEscape = true
case b == '\r' || b == '\n':
return string(key), nil
case b == 3: // Ctrl+C
return "", fmt.Errorf("interrupted")
case b == 4: // Ctrl+D
return "", fmt.Errorf("quit")
case b == 127 || b == 8: // Backspace / Delete
if len(key) > 0 {
key = key[:len(key)-1]
w.print("\b \b")
}
case b >= 32 && b < 127: // printable ASCII
if len(key) >= maxAPIKeyLength {
return "", errAPIKeyTooLong
}
key = append(key, b)
w.print("•")
}
if w.err != nil {
return "", w.err
}
}
}
}
// promptAPIKey shows a login screen and reads the API key interactively.
// Validates the key before returning. Loops on failure so the user can retry.
func promptAPIKey(s ssh.Session, serverURL string) (string, error) {
settingsURL := strings.TrimRight(serverURL, "/") + "/app/settings/accounts-access"
w := &sshWriter{w: s}
w.print("\r\n")
w.print(" \x1b[1;35mOnyx CLI\x1b[0m\r\n")
w.printf(" \x1b[90m%s\x1b[0m\r\n", serverURL)
w.print("\r\n")
w.print(" Generate an API key at:\r\n")
w.printf(" \x1b[4;34m%s\x1b[0m\r\n", settingsURL)
w.print("\r\n")
w.print(" \x1b[90mTip: skip this prompt by passing your key via SSH:\x1b[0m\r\n")
w.print(" \x1b[90m export ONYX_API_KEY=<key>\x1b[0m\r\n")
w.print(" \x1b[90m ssh -o SendEnv=ONYX_API_KEY <host> -p <port>\x1b[0m\r\n")
w.print("\r\n")
if w.err != nil {
return "", w.err
}
for {
w.print(" API Key: ")
if w.err != nil {
return "", w.err
}
key, err := readMasked(w, s)
if err != nil {
if errors.Is(err, errAPIKeyTooLong) {
w.print("\r\n")
w.printf(" \x1b[33m%s\x1b[0m\r\n\r\n", errAPIKeyTooLong.Error())
if w.err != nil {
return "", w.err
}
continue
}
w.print("\r\n")
return "", err
}
w.print("\r\n")
key = strings.TrimSpace(key)
if key == "" {
w.print(" \x1b[33mNo key entered.\x1b[0m\r\n\r\n")
if w.err != nil {
return "", w.err
}
continue
}
w.print(" \x1b[90mValidating…\x1b[0m")
if w.err != nil {
return "", w.err
}
err = validateAPIKey(serverURL, key)
w.print("\r\x1b[2K") // clear "Validating…" line
if err != nil {
w.printf(" \x1b[1;31m%s\x1b[0m\r\n\r\n", err.Error())
if w.err != nil {
return "", w.err
}
continue
}
w.print(" \x1b[32mAuthenticated.\x1b[0m\r\n\r\n")
return key, w.err
}
}
// authMiddleware prompts for an API key (or reads it from the session env)
// before handing off to the next handler (bubbletea).
func authMiddleware(serverCfg config.OnyxCliConfig) wish.Middleware {
return func(next ssh.Handler) ssh.Handler {
return func(s ssh.Session) {
w := &sshWriter{w: s}
apiKey := strings.TrimSpace(sessionEnv(s, config.EnvAPIKey))
if apiKey != "" {
if err := validateAPIKey(serverCfg.ServerURL, apiKey); err != nil {
w.print("\r\n")
w.print(" \x1b[33mThe ONYX_API_KEY provided via SSH environment is invalid.\x1b[0m\r\n")
w.printf(" \x1b[1;31m%s\x1b[0m\r\n\r\n", err.Error())
if w.err != nil {
return
}
apiKey = ""
}
}
if apiKey == "" {
var err error
apiKey, err = promptAPIKey(s, serverCfg.ServerURL)
if err != nil {
return
}
}
sessionAPIKeys.Store(s, apiKey)
defer sessionAPIKeys.Delete(s)
next(s)
}
}
}
func newServeCmd() *cobra.Command {
var (
host string
port int
keyPath string
idleTimeout time.Duration
maxSessionTimeout time.Duration
rateLimitPerMin int
rateLimitBurst int
rateLimitCache int
)
cmd := &cobra.Command{
Use: "serve",
Short: "Serve the Onyx TUI over SSH",
Long: `Start an SSH server that presents the interactive Onyx chat TUI to
connecting clients. Each SSH session gets its own independent TUI instance.
Clients are prompted for their Onyx API key on connect. The key can also be
provided via the ONYX_API_KEY environment variable to skip the prompt:
ssh -o SendEnv=ONYX_API_KEY host -p port
The server URL is taken from the server operator's config. The server
auto-generates an Ed25519 host key on first run if the key file does not
already exist.
Example:
onyx-cli serve --port 2222
ssh localhost -p 2222`,
RunE: func(cmd *cobra.Command, args []string) error {
serverCfg := config.Load()
if serverCfg.ServerURL == "" {
return fmt.Errorf("server URL is not configured; run 'onyx-cli configure' first")
}
if rateLimitPerMin <= 0 {
return fmt.Errorf("--rate-limit-per-minute must be > 0")
}
if rateLimitBurst <= 0 {
return fmt.Errorf("--rate-limit-burst must be > 0")
}
if rateLimitCache <= 0 {
return fmt.Errorf("--rate-limit-cache must be > 0")
}
addr := net.JoinHostPort(host, fmt.Sprintf("%d", port))
connectionLimiter := ratelimiter.NewRateLimiter(
rate.Limit(float64(rateLimitPerMin)/60.0),
rateLimitBurst,
rateLimitCache,
)
handler := func(s ssh.Session) (tea.Model, []tea.ProgramOption) {
apiKey := ""
if v, ok := sessionAPIKeys.Load(s); ok {
apiKey = v.(string)
}
cfg := config.OnyxCliConfig{
ServerURL: serverCfg.ServerURL,
APIKey: apiKey,
DefaultAgentID: serverCfg.DefaultAgentID,
}
m := tui.NewModel(cfg)
return m, []tea.ProgramOption{
tea.WithAltScreen(),
tea.WithMouseCellMotion(),
}
}
serverOptions := []ssh.Option{
wish.WithAddress(addr),
wish.WithHostKeyPath(keyPath),
wish.WithMiddleware(
bubbletea.Middleware(handler),
authMiddleware(serverCfg),
activeterm.Middleware(),
ratelimiter.Middleware(connectionLimiter),
logging.Middleware(),
),
}
if idleTimeout > 0 {
serverOptions = append(serverOptions, wish.WithIdleTimeout(idleTimeout))
}
if maxSessionTimeout > 0 {
serverOptions = append(serverOptions, wish.WithMaxTimeout(maxSessionTimeout))
}
s, err := wish.NewServer(serverOptions...)
if err != nil {
return fmt.Errorf("could not create SSH server: %w", err)
}
done := make(chan os.Signal, 1)
signal.Notify(done, os.Interrupt, syscall.SIGTERM)
log.Info("Starting Onyx SSH server", "addr", addr)
log.Info("Connect with", "cmd", fmt.Sprintf("ssh %s -p %d", host, port))
go func() {
if err := s.ListenAndServe(); err != nil && !errors.Is(err, ssh.ErrServerClosed) {
log.Error("SSH server failed", "error", err)
done <- nil
}
}()
<-done
log.Info("Shutting down SSH server")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return s.Shutdown(ctx)
},
}
cmd.Flags().StringVar(&host, "host", "localhost", "Host address to bind to")
cmd.Flags().IntVarP(&port, "port", "p", 2222, "Port to listen on")
cmd.Flags().StringVar(&keyPath, "host-key", ".ssh/onyx_serve_ed25519",
"Path to SSH host key (auto-generated if missing)")
cmd.Flags().DurationVar(
&idleTimeout,
"idle-timeout",
defaultServeIdleTimeout,
"Disconnect idle clients after this duration (set 0 to disable)",
)
cmd.Flags().DurationVar(
&maxSessionTimeout,
"max-session-timeout",
defaultServeMaxSessionTimeout,
"Maximum lifetime of a client session (set 0 to disable)",
)
cmd.Flags().IntVar(
&rateLimitPerMin,
"rate-limit-per-minute",
defaultServeRateLimitPerMinute,
"Per-IP connection rate limit (new sessions per minute)",
)
cmd.Flags().IntVar(
&rateLimitBurst,
"rate-limit-burst",
defaultServeRateLimitBurst,
"Per-IP burst limit for connection attempts",
)
cmd.Flags().IntVar(
&rateLimitCache,
"rate-limit-cache",
defaultServeRateLimitCacheSize,
"Maximum number of IP limiter entries tracked in memory",
)
return cmd
}

View File

@@ -3,60 +3,43 @@ module github.com/onyx-dot-app/onyx/cli
go 1.26.0
require (
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/glamour v0.10.0
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
github.com/charmbracelet/log v0.4.2
github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309
github.com/charmbracelet/wish v1.4.7
github.com/spf13/cobra v1.10.2
golang.org/x/term v0.40.0
github.com/charmbracelet/bubbles v0.20.0
github.com/charmbracelet/bubbletea v1.3.4
github.com/charmbracelet/glamour v0.8.0
github.com/charmbracelet/lipgloss v1.1.0
github.com/spf13/cobra v1.9.1
golang.org/x/term v0.30.0
golang.org/x/text v0.34.0
golang.org/x/time v0.14.0
)
require (
github.com/alecthomas/chroma/v2 v2.23.1 // indirect
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/charmbracelet/colorprofile v0.4.2 // indirect
github.com/charmbracelet/keygen v0.5.4 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/conpty v0.2.0 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20260305213658-fe36e8c10185 // indirect
github.com/charmbracelet/x/input v0.3.7 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/creack/pty v1.1.24 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/ansi v0.8.0 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/dlclark/regexp2 v1.11.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/go-logfmt/logfmt v0.6.1 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yuin/goldmark v1.7.16 // indirect
github.com/yuin/goldmark-emoji v1.0.6 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sys v0.41.0 // indirect
github.com/yuin/goldmark v1.7.4 // indirect
github.com/yuin/goldmark-emoji v1.0.3 // indirect
golang.org/x/net v0.38.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.31.0 // indirect
)

View File

@@ -1,89 +1,55 @@
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=
github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY=
github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk=
github.com/charmbracelet/keygen v0.5.4 h1:XQYgf6UEaTGgQSSmiPpIQ78WfseNQp4Pz8N/c1OsrdA=
github.com/charmbracelet/keygen v0.5.4/go.mod h1:t4oBRr41bvK7FaJsAaAQhhkUuHslzFXVjOBwA55CZNM=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig=
github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw=
github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309 h1:dCVbCRRtg9+tsfiTXTp0WupDlHruAXyp+YoxGVofHHc=
github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309/go.mod h1:R9cISUs5kAH4Cq/rguNbSwcR+slE5Dfm8FEs//uoIGE=
github.com/charmbracelet/wish v1.4.7 h1:O+jdLac3s6GaqkOHHSwezejNK04vl6VjO1A+hl8J8Yc=
github.com/charmbracelet/wish v1.4.7/go.mod h1:OBZ8vC62JC5cvbxJLh+bIWtG7Ctmct+ewziuUWK+G14=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/conpty v0.2.0 h1:eKtA2hm34qNfgJCDp/M6Dc0gLy7e07YEK4qAdNGOvVY=
github.com/charmbracelet/x/conpty v0.2.0/go.mod h1:fexgUnVrZgw8scD49f6VSi0Ggj9GWYIrpedRthAwW/8=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/exp/slice v0.0.0-20260305213658-fe36e8c10185 h1:bloHJLweYZeIkBVgi8AF94DrTdx3eoEB57VOpFuFi3U=
github.com/charmbracelet/x/exp/slice v0.0.0-20260305213658-fe36e8c10185/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
github.com/charmbracelet/x/input v0.3.7 h1:UzVbkt1vgM9dBQ+K+uRolBlN6IF2oLchmPKKo/aucXo=
github.com/charmbracelet/x/input v0.3.7/go.mod h1:ZSS9Cia6Cycf2T6ToKIOxeTBTDwl25AGwArJuGaOBH8=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/charmbracelet/bubbles v0.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE=
github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU=
github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI=
github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/glamour v0.8.0 h1:tPrjL3aRcQbn++7t18wOpgLyl8wrOHUEDS7IZ68QtZs=
github.com/charmbracelet/glamour v0.8.0/go.mod h1:ViRgmKkf3u5S7uakt2czJ272WSg2ZenlYEZXT2x7Bjw=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b h1:MnAMdlwSltxJyULnrYbkZpp4k58Co7Tah3ciKhSNo0Q=
github.com/charmbracelet/x/exp/golden v0.0.0-20240815200342-61de596daa2b/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE=
github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
@@ -94,45 +60,35 @@ github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark v1.7.4 h1:BDXOHExt+A7gwPCJgPIIq7ENvceR7we7rOS9TNoLZeg=
github.com/yuin/goldmark v1.7.4/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark-emoji v1.0.3 h1:aLRkLHOuBR2czCY4R8olwMjID+tENfhyFDMCRhbIQY4=
github.com/yuin/goldmark-emoji v1.0.3/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -26,16 +26,20 @@ class CustomBuildHook(BuildHookInterface):
# Get config and environment
binary_name = self.config["binary_name"]
tag_prefix = self.config.get("tag_prefix", binary_name)
tag = os.getenv("GITHUB_REF_NAME", "dev").removeprefix(f"{tag_prefix}/")
tag = os.getenv("GITHUB_REF_NAME", "dev").removeprefix(f"{binary_name}/")
commit = os.getenv("GITHUB_SHA", "none")
# Build the Go binary if it doesn't exist
if not os.path.exists(binary_name):
print(f"Building Go binary '{binary_name}'...")
ldflags = f"-X main.version={tag} -X main.commit={commit} -s -w"
subprocess.check_call( # noqa: S603
["go", "build", f"-ldflags={ldflags}", "-o", binary_name],
[
"go",
"build",
f"-ldflags=-X main.version={tag} -X main.commit={commit} -s -w",
"-o",
binary_name,
],
)
build_data["shared_scripts"] = {binary_name: binary_name}

View File

@@ -3,9 +3,6 @@ from __future__ import annotations
import os
import re
# Must match tag_prefix in pyproject.toml [tool.hatch.build.targets.wheel.hooks.custom]
TAG_PREFIX: str = "ods"
_tag = os.environ.get("GITHUB_REF_NAME", "v0.0.0-dev").removeprefix(f"{TAG_PREFIX}/")
_tag = os.environ.get("GITHUB_REF_NAME", "v0.0.0-dev").removeprefix("ods/")
_match = re.search(r"v?(\d+\.\d+\.\d+)", _tag)
__version__ = _match.group(1) if _match else "0.0.0"

View File

@@ -14,9 +14,7 @@ keywords = [
classifiers = [
"Programming Language :: Go",
"License :: OSI Approved :: MIT License",
"Operating System :: POSIX :: Linux",
"Operating System :: MacOS",
"Operating System :: Microsoft :: Windows",
"Operating System :: OS Independent",
]
dynamic = ["version"]
dependencies = [
@@ -29,7 +27,7 @@ dependencies = [
Repository = "https://github.com/onyx-dot-app/onyx"
[tool.hatch.build]
include = ["go.mod", "go.sum", "main.go", "**/*.go", "**/*.py", "README.md"]
include = ["go.mod", "go.sum", "main.go", "**/*.go", "**/*.py"]
[tool.hatch.version]
source = "code"
@@ -38,7 +36,6 @@ path = "internal/_version.py"
[tool.hatch.build.targets.wheel.hooks.custom]
path = "hatch_build.py"
binary_name = "ods"
tag_prefix = "ods"
[tool.uv]
managed = false

View File

@@ -6,7 +6,7 @@ import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
import Text from "@/refresh-components/texts/Text";
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
import InputSelect from "@/refresh-components/inputs/InputSelect";
import InputComboBox from "@/refresh-components/inputs/InputComboBox";
import { FormikField } from "@/refresh-components/form/FormikField";
import { FormField } from "@/refresh-components/form/FormField";
import { USER_ROLE_LABELS, UserRole } from "@/lib/types";
@@ -107,25 +107,26 @@ export default function OnyxApiKeyForm({
<FormField name="role" state={state} className="w-full">
<FormField.Label>Role:</FormField.Label>
<FormField.Control>
<InputSelect
<InputComboBox
value={field.value}
onValueChange={(value) => helper.setValue(value)}
>
<InputSelect.Trigger placeholder="Select a role" />
<InputSelect.Content>
<InputSelect.Item
value={UserRole.LIMITED.toString()}
>
{USER_ROLE_LABELS[UserRole.LIMITED]}
</InputSelect.Item>
<InputSelect.Item value={UserRole.BASIC.toString()}>
{USER_ROLE_LABELS[UserRole.BASIC]}
</InputSelect.Item>
<InputSelect.Item value={UserRole.ADMIN.toString()}>
{USER_ROLE_LABELS[UserRole.ADMIN]}
</InputSelect.Item>
</InputSelect.Content>
</InputSelect>
options={[
{
label: USER_ROLE_LABELS[UserRole.LIMITED],
value: UserRole.LIMITED.toString(),
},
{
label: USER_ROLE_LABELS[UserRole.BASIC],
value: UserRole.BASIC.toString(),
},
{
label: USER_ROLE_LABELS[UserRole.ADMIN],
value: UserRole.ADMIN.toString(),
},
]}
placeholder="Select a role"
strict
/>
</FormField.Control>
<FormField.Description>
Select the role for this API key. Limited has access to

View File

@@ -0,0 +1 @@
export { default } from "@/refresh-pages/admin/UsersPage";

View File

@@ -31,6 +31,7 @@ const SETTINGS_LAYOUT_PREFIXES = [
ADMIN_PATHS.LLM_MODELS,
ADMIN_PATHS.AGENTS,
ADMIN_PATHS.USERS,
ADMIN_PATHS.USERS_V2,
ADMIN_PATHS.TOKEN_RATE_LIMITS,
ADMIN_PATHS.SEARCH_SETTINGS,
ADMIN_PATHS.DOCUMENT_PROCESSING,

View File

@@ -0,0 +1,46 @@
"use client";
import useSWR from "swr";
import { errorHandlingFetcher } from "@/lib/fetcher";
import type { UserRole } from "@/lib/types";
import type { PaginatedUsersResponse } from "@/refresh-pages/admin/UsersPage/interfaces";
interface UseAdminUsersParams {
pageIndex: number;
pageSize: number;
searchTerm?: string;
roles?: UserRole[];
isActive?: boolean | undefined;
}
export default function useAdminUsers({
pageIndex,
pageSize,
searchTerm,
roles,
isActive,
}: UseAdminUsersParams) {
const queryParams = new URLSearchParams({
page_num: String(pageIndex),
page_size: String(pageSize),
...(searchTerm && { q: searchTerm }),
...(isActive === true && { is_active: "true" }),
...(isActive === false && { is_active: "false" }),
});
for (const role of roles ?? []) {
queryParams.append("roles", role);
}
const { data, isLoading, error, mutate } = useSWR<PaginatedUsersResponse>(
`/api/manage/users/accepted?${queryParams.toString()}`,
errorHandlingFetcher
);
return {
users: data?.items ?? [],
totalItems: data?.total_items ?? 0,
isLoading,
error,
refresh: mutate,
};
}

View File

@@ -0,0 +1,34 @@
"use client";
import useSWR from "swr";
import { errorHandlingFetcher } from "@/lib/fetcher";
import type { InvitedUserSnapshot } from "@/lib/types";
import { NEXT_PUBLIC_CLOUD_ENABLED } from "@/lib/constants";
interface PaginatedCountResponse {
total_items: number;
}
export default function useUserCounts() {
// Active user count — lightweight fetch (page_size=1 to minimize payload)
const { data: activeData } = useSWR<PaginatedCountResponse>(
"/api/manage/users/accepted?page_num=0&page_size=1",
errorHandlingFetcher
);
const { data: invitedUsers } = useSWR<InvitedUserSnapshot[]>(
"/api/manage/users/invited",
errorHandlingFetcher
);
const { data: pendingUsers } = useSWR<InvitedUserSnapshot[]>(
NEXT_PUBLIC_CLOUD_ENABLED ? "/api/tenants/users/pending" : null,
errorHandlingFetcher
);
return {
activeCount: activeData?.total_items ?? null,
invitedCount: invitedUsers?.length ?? null,
pendingCount: pendingUsers?.length ?? null,
};
}

View File

@@ -230,7 +230,7 @@ function SettingsHeader({
</div>
)}
<Spacer vertical rem={1} />
<Spacer vertical rem={2.5} />
<div className="flex flex-col gap-6 px-4">
<div className="flex w-full justify-between">

View File

@@ -58,6 +58,7 @@ export const ADMIN_PATHS = {
DOCUMENT_PROCESSING: "/admin/configuration/document-processing",
KNOWLEDGE_GRAPH: "/admin/kg",
USERS: "/admin/users",
USERS_V2: "/admin/users2",
API_KEYS: "/admin/api-key",
TOKEN_RATE_LIMITS: "/admin/token-rate-limits",
USAGE: "/admin/performance/usage",
@@ -190,6 +191,11 @@ export const ADMIN_ROUTE_CONFIG: Record<string, AdminRouteConfig> = {
title: "Manage Users",
sidebarLabel: "Users",
},
[ADMIN_PATHS.USERS_V2]: {
icon: SvgUser,
title: "Users & Requests",
sidebarLabel: "Users v2",
},
[ADMIN_PATHS.API_KEYS]: {
icon: SvgKey,
title: "API Keys",

View File

@@ -57,12 +57,6 @@ export interface LineItemProps
WithoutStyles<React.HTMLAttributes<HTMLDivElement>>,
"children"
> {
/**
* Whether the row should behave like a standalone interactive button.
* Set to false when nested inside another interactive primitive
* (e.g. Radix Select.Item) to avoid nested focus targets.
*/
interactive?: boolean;
// line-item variants
strikethrough?: boolean;
danger?: boolean;
@@ -137,7 +131,6 @@ export interface LineItemProps
* - The component automatically adds a `data-selected="true"` attribute for custom styling
*/
export default function LineItem({
interactive = true,
selected,
strikethrough,
danger,
@@ -171,11 +164,6 @@ export default function LineItem({
const emphasisKey = emphasized ? "emphasized" : "normal";
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (!interactive) {
props.onKeyDown?.(e);
return;
}
if (e.key === "Enter") {
e.preventDefault();
(e.currentTarget as HTMLDivElement).click();
@@ -186,11 +174,6 @@ export default function LineItem({
};
const handleKeyUp = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (!interactive) {
props.onKeyUp?.(e);
return;
}
if (e.key === " ") {
e.preventDefault();
(e.currentTarget as HTMLDivElement).click();
@@ -201,8 +184,8 @@ export default function LineItem({
const content = (
<div
ref={ref}
role={interactive ? "button" : undefined}
tabIndex={interactive ? 0 : undefined}
role="button"
tabIndex={0}
className={cn(
"flex flex-row w-full items-start p-2 rounded-08 group/LineItem gap-2",
!!(children && description) ? "items-start" : "items-center",

View File

@@ -369,7 +369,7 @@ function InputSelectItem({
<SelectPrimitive.Item
ref={ref}
value={value}
className="outline-none focus:outline-none rounded-08 data-[highlighted]:bg-background-tint-02"
className="outline-none focus:outline-none"
onSelect={onClick}
>
{/* Hidden ItemText for Radix to track selection */}
@@ -383,7 +383,7 @@ function InputSelectItem({
selected={isSelected}
emphasized
description={description}
interactive={false}
onClick={noProp((event) => event.preventDefault())}
>
{children}
</LineItem>

View File

@@ -193,7 +193,7 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
onSubmit({
message,
currentMessageFiles,
deepResearch: deepResearchEnabledForCurrentWorkflow,
deepResearch: deepResearchEnabled,
});
}
}
@@ -218,8 +218,6 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
chatSessionId: currentChatSessionId,
agentId: selectedAgent?.id,
});
const deepResearchEnabledForCurrentWorkflow =
currentProjectId === null && deepResearchEnabled;
const [presentingDocument, setPresentingDocument] =
useState<MinimalOnyxDocument | null>(null);
@@ -437,15 +435,10 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
onSubmit({
message: lastUserMsg.message,
currentMessageFiles: currentMessageFiles,
deepResearch: deepResearchEnabledForCurrentWorkflow,
deepResearch: deepResearchEnabled,
messageIdToResend: lastUserMsg.messageId,
});
}, [
messageHistory,
onSubmit,
currentMessageFiles,
deepResearchEnabledForCurrentWorkflow,
]);
}, [messageHistory, onSubmit, currentMessageFiles, deepResearchEnabled]);
const toggleDocumentSidebar = useCallback(() => {
if (!documentSidebarVisible) {
@@ -465,7 +458,7 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
onSubmit({
message,
currentMessageFiles,
deepResearch: deepResearchEnabledForCurrentWorkflow,
deepResearch: deepResearchEnabled,
});
if (showOnboarding || !onboardingDismissed) {
finishOnboarding();
@@ -475,7 +468,7 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
resetInputBar,
onSubmit,
currentMessageFiles,
deepResearchEnabledForCurrentWorkflow,
deepResearchEnabled,
showOnboarding,
onboardingDismissed,
finishOnboarding,
@@ -510,7 +503,7 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
onSubmit({
message,
currentMessageFiles,
deepResearch: deepResearchEnabledForCurrentWorkflow,
deepResearch: deepResearchEnabled,
});
if (showOnboarding || !onboardingDismissed) {
finishOnboarding();
@@ -531,7 +524,7 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
resetInputBar,
onSubmit,
currentMessageFiles,
deepResearchEnabledForCurrentWorkflow,
deepResearchEnabled,
showOnboarding,
onboardingDismissed,
finishOnboarding,
@@ -716,7 +709,7 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
>
{/* Main content grid — 3 rows, animated */}
<div
className="flex-1 w-full grid min-h-0 px-4 transition-[grid-template-rows] duration-150 ease-in-out"
className="flex-1 w-full grid min-h-0 transition-[grid-template-rows] duration-150 ease-in-out"
style={gridStyle}
>
{/* ── Top row: ChatUI / WelcomeMessage / ProjectUI ── */}
@@ -739,9 +732,7 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
<ChatUI
liveAgent={liveAgent!}
llmManager={llmManager}
deepResearchEnabled={
deepResearchEnabledForCurrentWorkflow
}
deepResearchEnabled={deepResearchEnabled}
currentMessageFiles={currentMessageFiles}
setPresentingDocument={setPresentingDocument}
onSubmit={onSubmit}
@@ -837,9 +828,7 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
/>
<AppInputBar
ref={chatInputBarRef}
deepResearchEnabled={
deepResearchEnabledForCurrentWorkflow
}
deepResearchEnabled={deepResearchEnabled}
toggleDeepResearch={toggleDeepResearch}
filterManager={filterManager}
llmManager={llmManager}

View File

@@ -0,0 +1,59 @@
"use client";
import { SvgUser, SvgUserPlus } from "@opal/icons";
import { Button } from "@opal/components";
import * as SettingsLayouts from "@/layouts/settings-layouts";
import { useScimToken } from "@/hooks/useScimToken";
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
import useUserCounts from "@/hooks/useUserCounts";
import UsersSummary from "./UsersPage/UsersSummary";
import UsersTable from "./UsersPage/UsersTable";
// ---------------------------------------------------------------------------
// Users page content
// ---------------------------------------------------------------------------
function UsersContent() {
const isEe = usePaidEnterpriseFeaturesEnabled();
const { data: scimToken } = useScimToken();
const showScim = isEe && !!scimToken;
const { activeCount, invitedCount, pendingCount } = useUserCounts();
return (
<>
<UsersSummary
activeUsers={activeCount}
pendingInvites={invitedCount}
requests={pendingCount}
showScim={showScim}
/>
<UsersTable />
</>
);
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default function UsersPage() {
return (
<SettingsLayouts.Root width="lg">
<SettingsLayouts.Header
title="Users & Requests"
icon={SvgUser}
rightChildren={
// TODO (ENG-3806): Wire up invite modal
<Button icon={SvgUserPlus}>Invite Users</Button>
}
/>
<SettingsLayouts.Body>
<UsersContent />
</SettingsLayouts.Body>
</SettingsLayouts.Root>
);
}

View File

@@ -0,0 +1,104 @@
import { SvgArrowUpRight, SvgUserSync } from "@opal/icons";
import { ContentAction } from "@opal/layouts";
import { Button } from "@opal/components";
import { Section } from "@/layouts/general-layouts";
import Card from "@/refresh-components/cards/Card";
import Text from "@/refresh-components/texts/Text";
import Link from "next/link";
import { ADMIN_PATHS } from "@/lib/admin-routes";
// ---------------------------------------------------------------------------
// Stats cell — number + label
// ---------------------------------------------------------------------------
interface StatCellProps {
value: number | null;
label: string;
}
function StatCell({ value, label }: StatCellProps) {
const display = value === null ? "—" : value.toLocaleString();
return (
<div className="flex flex-col items-start gap-0.5 w-full p-2">
<Text as="span" mainUiAction text04>
{display}
</Text>
<Text as="span" secondaryBody text03>
{label}
</Text>
</div>
);
}
// ---------------------------------------------------------------------------
// SCIM card
// ---------------------------------------------------------------------------
function ScimCard() {
return (
<Card gap={0.5} padding={0.75}>
<ContentAction
icon={SvgUserSync}
title="SCIM Sync"
description="Users are synced from your identity provider."
sizePreset="main-ui"
variant="section"
paddingVariant="fit"
rightChildren={
<Link href={ADMIN_PATHS.SCIM}>
<Button prominence="tertiary" rightIcon={SvgArrowUpRight} size="sm">
Manage
</Button>
</Link>
}
/>
</Card>
);
}
// ---------------------------------------------------------------------------
// Stats bar — layout varies by SCIM status
// ---------------------------------------------------------------------------
interface UsersSummaryProps {
activeUsers: number | null;
pendingInvites: number | null;
requests: number | null;
showScim: boolean;
}
export default function UsersSummary({
activeUsers,
pendingInvites,
requests,
showScim,
}: UsersSummaryProps) {
const showRequests = requests !== null && requests > 0;
const statsCard = (
<Card padding={0.5}>
<Section flexDirection="row" gap={0}>
<StatCell value={activeUsers} label="active users" />
<StatCell value={pendingInvites} label="pending invites" />
{showRequests && <StatCell value={requests} label="requests to join" />}
</Section>
</Card>
);
if (showScim) {
return (
<Section
flexDirection="row"
justifyContent="start"
alignItems="stretch"
gap={0.5}
>
{statsCard}
<ScimCard />
</Section>
);
}
return statsCard;
}

View File

@@ -0,0 +1,203 @@
"use client";
import { useState } from "react";
import type { SortingState } from "@tanstack/react-table";
import DataTable from "@/refresh-components/table/DataTable";
import { createTableColumns } from "@/refresh-components/table/columns";
import { Content } from "@opal/layouts";
import { USER_ROLE_LABELS, UserRole } from "@/lib/types";
import { timeAgo } from "@/lib/time";
import Text from "@/refresh-components/texts/Text";
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
import useAdminUsers from "@/hooks/useAdminUsers";
import { SvgUser, SvgUsers, SvgSlack } from "@opal/icons";
import type { IconFunctionComponent } from "@opal/types";
import type { UserRow } from "./interfaces";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function getInitials(name: string | null, email: string): string {
if (name) {
const parts = name.trim().split(/\s+/);
if (parts.length >= 2) {
return ((parts[0]?.[0] ?? "") + (parts[1]?.[0] ?? "")).toUpperCase();
}
return name.slice(0, 2).toUpperCase();
}
const local = email.split("@")[0];
if (!local) return "?";
const parts = local.split(/[._-]/);
if (parts.length >= 2) {
return ((parts[0]?.[0] ?? "") + (parts[1]?.[0] ?? "")).toUpperCase();
}
return local.slice(0, 2).toUpperCase();
}
const ROLE_ICONS: Record<UserRole, IconFunctionComponent> = {
[UserRole.BASIC]: SvgUser,
[UserRole.ADMIN]: SvgUser,
[UserRole.GLOBAL_CURATOR]: SvgUsers,
[UserRole.CURATOR]: SvgUsers,
[UserRole.LIMITED]: SvgUser,
[UserRole.EXT_PERM_USER]: SvgUser,
[UserRole.SLACK_USER]: SvgSlack,
};
// ---------------------------------------------------------------------------
// Columns (stable reference — defined at module scope)
// ---------------------------------------------------------------------------
const tc = createTableColumns<UserRow>();
const columns = [
tc.qualifier({
content: "avatar-user",
getInitials: (row) => getInitials(row.personal_name, row.email),
selectable: false,
}),
tc.column("email", {
header: "Name",
weight: 25,
minWidth: 180,
cell: (value, row) => (
<Content
sizePreset="main-ui"
variant="section"
title={row.personal_name ?? value}
description={row.personal_name ? value : undefined}
/>
),
}),
tc.column("groups", {
header: "Groups",
weight: 20,
minWidth: 120,
cell: (value) => {
if (!value.length) {
return (
<Text as="span" secondaryBody text03>
</Text>
);
}
const visible = value.slice(0, 2);
const overflow = value.length - visible.length;
return (
<div className="flex items-center gap-1 flex-wrap">
{visible.map((g) => (
<span
key={g.id}
className="inline-flex items-center rounded-md bg-background-tint-02 px-2 py-0.5"
>
<Text as="span" secondaryBody text03>
{g.name}
</Text>
</span>
))}
{overflow > 0 && (
<Text as="span" secondaryBody text03>
+{overflow}
</Text>
)}
</div>
);
},
}),
tc.column("role", {
header: "Account Type",
weight: 18,
minWidth: 120,
cell: (value) => {
const Icon = ROLE_ICONS[value];
return (
<div className="flex items-center gap-1.5">
{Icon && <Icon size={14} className="text-text-03 shrink-0" />}
<Text as="span" mainUiBody text03>
{USER_ROLE_LABELS[value] ?? value}
</Text>
</div>
);
},
}),
tc.column("is_active", {
header: "Status",
weight: 15,
minWidth: 100,
cell: (value, row) => (
<div className="flex flex-col">
<Text as="span" mainUiBody text03>
{value ? "Active" : "Inactive"}
</Text>
{row.is_scim_synced && (
<Text as="span" secondaryBody text03>
SCIM synced
</Text>
)}
</div>
),
}),
tc.column("updated_at", {
header: "Last Updated",
weight: 14,
minWidth: 100,
cell: (value) => (
<Text as="span" secondaryBody text03>
{timeAgo(value) ?? "—"}
</Text>
),
}),
tc.actions(),
];
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
const PAGE_SIZE = 10;
export default function UsersTable() {
const [searchTerm, setSearchTerm] = useState("");
const [sorting, setSorting] = useState<SortingState>([]);
const [pageIndex, setPageIndex] = useState(0);
const { users, totalItems, isLoading } = useAdminUsers({
pageIndex,
pageSize: PAGE_SIZE,
searchTerm: searchTerm || undefined,
});
return (
<div className="flex flex-col gap-3">
<InputTypeIn
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
setPageIndex(0);
}}
placeholder="Search users..."
leftSearchIcon
/>
<DataTable
data={users}
columns={columns}
getRowId={(row) => row.id}
pageSize={PAGE_SIZE}
searchTerm={searchTerm}
footer={{ mode: "summary" }}
serverSide={{
totalItems,
isLoading,
onSortingChange: setSorting,
onPaginationChange: (idx) => {
setPageIndex(idx);
},
onSearchTermChange: () => {
// search state managed via searchTerm prop above
},
}}
/>
</div>
);
}

View File

@@ -0,0 +1,25 @@
import type { UserRole } from "@/lib/types";
export interface UserGroupInfo {
id: number;
name: string;
}
export interface UserRow {
id: string;
email: string;
role: UserRole;
is_active: boolean;
is_scim_synced: boolean;
personal_name: string | null;
created_at: string;
updated_at: string;
groups: UserGroupInfo[];
}
export interface PaginatedUsersResponse {
items: UserRow[];
total_items: number;
}
export type StatusFilter = "all" | "active" | "inactive";

View File

@@ -148,7 +148,7 @@ const AppInputBar = React.memo(
classification === "search";
const { forcedToolIds, setForcedToolIds } = useForcedTools();
const { currentMessageFiles, setCurrentMessageFiles, currentProjectId } =
const { currentMessageFiles, setCurrentMessageFiles } =
useProjectsContext();
const currentIndexingFiles = useMemo(() => {
@@ -200,17 +200,9 @@ const AppInputBar = React.memo(
const textarea = textAreaRef.current;
if (!wrapper || !textarea) return;
// Reset so scrollHeight reflects actual content size
wrapper.style.height = `${MIN_INPUT_HEIGHT}px`;
// scrollHeight doesn't include the wrapper's padding, so add it back
const wrapperStyle = getComputedStyle(wrapper);
const paddingTop = parseFloat(wrapperStyle.paddingTop);
const paddingBottom = parseFloat(wrapperStyle.paddingBottom);
const contentHeight = textarea.scrollHeight + paddingTop + paddingBottom;
wrapper.style.height = `${Math.min(
Math.max(contentHeight, MIN_INPUT_HEIGHT),
Math.max(textarea.scrollHeight, MIN_INPUT_HEIGHT),
MAX_INPUT_HEIGHT
)}px`;
}, [message, isSearchMode]);
@@ -366,19 +358,13 @@ const AppInputBar = React.memo(
const showDeepResearch = useMemo(() => {
const deepResearchGloballyEnabled =
combinedSettings?.settings?.deep_research_enabled ?? true;
const isProjectWorkflow = currentProjectId !== null;
// TODO(@yuhong): Re-enable Deep Research in Projects workflow once it is fully supported.
// https://linear.app/onyx-app/issue/ENG-3818/re-enable-deep-research-in-projects
return (
!isProjectWorkflow &&
deepResearchGloballyEnabled &&
hasSearchToolsAvailable(selectedAgent?.tools || [])
);
}, [
selectedAgent?.tools,
combinedSettings?.settings?.deep_research_enabled,
currentProjectId,
]);
function handleKeyDownForPromptShortcuts(

View File

@@ -127,14 +127,13 @@ const collections = (
sidebarItem(ADMIN_PATHS.TOKEN_RATE_LIMITS),
],
},
...(enableEnterprise
? [
{
name: "Permissions",
items: [sidebarItem(ADMIN_PATHS.SCIM)],
},
]
: []),
{
name: "Permissions",
items: [
sidebarItem(ADMIN_PATHS.USERS_V2),
...(enableEnterprise ? [sidebarItem(ADMIN_PATHS.SCIM)] : []),
],
},
...(enableEnterprise
? [
{