Compare commits

...

3 Commits

Author SHA1 Message Date
Yuhong Sun
b1e92d8e8f k 2026-01-23 17:17:28 -08:00
Danelegend
0594fd17de chore(tests): add more packet tests (#7677) 2026-01-23 19:49:41 +00:00
Jamison Lahman
fded81dc28 chore(extensions): pull in chrome extension (#7703) 2026-01-23 10:17:05 -08:00
38 changed files with 5669 additions and 125 deletions

View File

@@ -287,6 +287,7 @@ def run_deep_research_llm_loop(
token_count=100,
message_type=MessageType.USER,
)
truncated_message_history = construct_message_history(
system_prompt=system_prompt,
custom_agent_prompt=None,

View File

@@ -14,6 +14,10 @@ from onyx.llm.constants import LlmProviderNames
from onyx.server.manage.llm.models import LLMProviderUpsertRequest
# Counter for generating unique file IDs in mock file store
_mock_file_id_counter = 0
def ensure_default_llm_provider(db_session: Session) -> None:
"""Ensure a default LLM provider exists for tests that exercise chat flows."""
@@ -80,11 +84,34 @@ def mock_vespa_query() -> Iterator[None]:
yield
@pytest.fixture
def mock_file_store() -> Iterator[None]:
"""Mock the file store to avoid S3/storage dependencies in tests."""
global _mock_file_id_counter
def _mock_save_file(*args: Any, **kwargs: Any) -> str:
global _mock_file_id_counter
_mock_file_id_counter += 1
# Return a predictable file ID for tests
return "123"
mock_store = MagicMock()
mock_store.save_file.side_effect = _mock_save_file
mock_store.initialize.return_value = None
with patch(
"onyx.file_store.utils.get_default_file_store",
return_value=mock_store,
):
yield
@pytest.fixture
def mock_external_deps(
mock_nlp_embeddings_post: None,
mock_gpu_status: None,
mock_vespa_query: None,
mock_file_store: None,
) -> Iterator[None]:
"""Convenience fixture to enable all common external dependency mocks."""
yield

View File

@@ -0,0 +1,156 @@
from __future__ import annotations
from typing import cast
from onyx.chat.models import AnswerStreamPart
from onyx.chat.models import CreateChatSessionID
from onyx.chat.models import MessageResponseIDInfo
from onyx.context.search.models import SearchDoc
from onyx.server.query_and_chat.streaming_models import AgentResponseStart
from onyx.server.query_and_chat.streaming_models import ImageGenerationFinal
from onyx.server.query_and_chat.streaming_models import OpenUrlDocuments
from onyx.server.query_and_chat.streaming_models import Packet
from onyx.server.query_and_chat.streaming_models import SearchToolDocumentsDelta
def assert_answer_stream_part_correct(
received: AnswerStreamPart, expected: AnswerStreamPart
) -> None:
assert isinstance(received, type(expected))
if isinstance(received, Packet):
r_packet = cast(Packet, received)
e_packet = cast(Packet, expected)
assert r_packet.placement == e_packet.placement
if isinstance(r_packet.obj, SearchToolDocumentsDelta):
assert isinstance(e_packet.obj, SearchToolDocumentsDelta)
assert is_search_tool_document_delta_equal(r_packet.obj, e_packet.obj)
return
elif isinstance(r_packet.obj, OpenUrlDocuments):
assert isinstance(e_packet.obj, OpenUrlDocuments)
assert is_open_url_documents_equal(r_packet.obj, e_packet.obj)
return
elif isinstance(r_packet.obj, AgentResponseStart):
assert isinstance(e_packet.obj, AgentResponseStart)
assert is_agent_response_start_equal(r_packet.obj, e_packet.obj)
return
elif isinstance(r_packet.obj, ImageGenerationFinal):
assert isinstance(e_packet.obj, ImageGenerationFinal)
assert is_image_generation_final_equal(r_packet.obj, e_packet.obj)
return
assert r_packet.obj == e_packet.obj
elif isinstance(received, MessageResponseIDInfo):
# We're not going to make assumptions about what the user id / assistant id should be
# So just return
return
elif isinstance(received, CreateChatSessionID):
# Don't worry about same session ids
return
else:
raise NotImplementedError("Not implemented")
def _are_search_docs_equal(
received: list[SearchDoc],
expected: list[SearchDoc],
) -> bool:
"""
What we care about:
- All documents are present (order does not)
- Expected document_id, link, blurb, source_type and hidden
"""
if len(received) != len(expected):
return False
received.sort(key=lambda x: x.document_id)
expected.sort(key=lambda x: x.document_id)
for received_document, expected_document in zip(received, expected):
if received_document.document_id != expected_document.document_id:
return False
if received_document.link != expected_document.link:
return False
if received_document.blurb != expected_document.blurb:
return False
if received_document.source_type != expected_document.source_type:
return False
if received_document.hidden != expected_document.hidden:
return False
return True
def is_search_tool_document_delta_equal(
received: SearchToolDocumentsDelta,
expected: SearchToolDocumentsDelta,
) -> bool:
"""
What we care about:
- All documents are present (order does not)
- Expected document_id, link, blurb, source_type and hidden
"""
received_documents = received.documents
expected_documents = expected.documents
return _are_search_docs_equal(received_documents, expected_documents)
def is_open_url_documents_equal(
received: OpenUrlDocuments,
expected: OpenUrlDocuments,
) -> bool:
"""
What we care about:
- All documents are present (order does not)
- Expected document_id, link, blurb, source_type and hidden
"""
received_documents = received.documents
expected_documents = expected.documents
return _are_search_docs_equal(received_documents, expected_documents)
def is_agent_response_start_equal(
received: AgentResponseStart,
expected: AgentResponseStart,
) -> bool:
"""
What we care about:
- All documents are present (order does not)
- Expected document_id, link, blurb, source_type and hidden
"""
received_documents = received.final_documents
expected_documents = expected.final_documents
if received_documents is None and expected_documents is None:
return True
if not received_documents or not expected_documents:
return False
return _are_search_docs_equal(received_documents, expected_documents)
def is_image_generation_final_equal(
received: ImageGenerationFinal,
expected: ImageGenerationFinal,
) -> bool:
"""
What we care about:
- Number of images are the same
- On each image, url and file_id are aligned such that url=/api/chat/file/{file_id}
- Revised prompt is expected
- Shape is expected
"""
if len(received.images) != len(expected.images):
return False
for received_image, expected_image in zip(received.images, expected.images):
if received_image.url != f"/api/chat/file/{received_image.file_id}":
return False
if received_image.revised_prompt != expected_image.revised_prompt:
return False
if received_image.shape != expected_image.shape:
return False
return True

View File

@@ -0,0 +1,139 @@
from __future__ import annotations
from collections.abc import Iterator
from onyx.chat.models import AnswerStreamPart
from onyx.context.search.models import SearchDoc
from onyx.server.query_and_chat.streaming_models import AgentResponseStart
from onyx.server.query_and_chat.streaming_models import OverallStop
from onyx.server.query_and_chat.streaming_models import Packet
from onyx.server.query_and_chat.streaming_models import ReasoningDone
from onyx.server.query_and_chat.streaming_models import ReasoningStart
from tests.external_dependency_unit.answer.stream_test_assertions import (
assert_answer_stream_part_correct,
)
from tests.external_dependency_unit.answer.stream_test_utils import (
create_packet_with_agent_response_delta,
)
from tests.external_dependency_unit.answer.stream_test_utils import (
create_packet_with_reasoning_delta,
)
from tests.external_dependency_unit.answer.stream_test_utils import create_placement
from tests.external_dependency_unit.mock_llm import LLMResponse
from tests.external_dependency_unit.mock_llm import MockLLMController
class StreamTestBuilder:
def __init__(self, llm_controller: MockLLMController) -> None:
self._llm_controller = llm_controller
# List of (expected_packet, forward_count) tuples
self._expected_packets_queue: list[tuple[Packet, int]] = []
def add_response(self, response: LLMResponse) -> StreamTestBuilder:
self._llm_controller.add_response(response)
return self
def add_responses_together(self, *responses: LLMResponse) -> StreamTestBuilder:
"""Add multiple responses that should be emitted together in the same tick."""
self._llm_controller.add_responses_together(*responses)
return self
def expect(
self, expected_pkt: Packet, forward: int | bool = True
) -> StreamTestBuilder:
"""
Add an expected packet to the queue.
Args:
expected_pkt: The packet to expect
forward: Number of tokens to forward before expecting this packet.
True = 1 token, False = 0 tokens, int = that many tokens.
"""
forward_count = 1 if forward is True else (0 if forward is False else forward)
self._expected_packets_queue.append((expected_pkt, forward_count))
return self
def expect_packets(
self, packets: list[Packet], forward: int | bool = True
) -> StreamTestBuilder:
"""
Add multiple expected packets to the queue.
Args:
packets: List of packets to expect
forward: Number of tokens to forward before expecting EACH packet.
True = 1 token per packet, False = 0 tokens, int = that many tokens per packet.
"""
forward_count = 1 if forward is True else (0 if forward is False else forward)
for pkt in packets:
self._expected_packets_queue.append((pkt, forward_count))
return self
def expect_reasoning(
self,
reasoning_tokens: list[str],
turn_index: int,
) -> StreamTestBuilder:
return (
self.expect(
Packet(
placement=create_placement(turn_index),
obj=ReasoningStart(),
)
)
.expect_packets(
[
create_packet_with_reasoning_delta(token, turn_index)
for token in reasoning_tokens
]
)
.expect(
Packet(
placement=create_placement(turn_index),
obj=ReasoningDone(),
)
)
)
def expect_agent_response(
self,
answer_tokens: list[str],
turn_index: int,
final_documents: list[SearchDoc] | None = None,
) -> StreamTestBuilder:
return (
self.expect(
Packet(
placement=create_placement(turn_index),
obj=AgentResponseStart(
final_documents=final_documents,
),
)
)
.expect_packets(
[
create_packet_with_agent_response_delta(token, turn_index)
for token in answer_tokens
]
)
.expect(
Packet(
placement=create_placement(turn_index),
obj=OverallStop(),
)
)
)
def run_and_validate(self, stream: Iterator[AnswerStreamPart]) -> None:
while self._expected_packets_queue:
expected_pkt, forward_count = self._expected_packets_queue.pop(0)
if forward_count > 0:
self._llm_controller.forward(forward_count)
received_pkt = next(stream)
assert_answer_stream_part_correct(received_pkt, expected_pkt)

View File

@@ -0,0 +1,121 @@
from __future__ import annotations
from collections.abc import Iterator
from uuid import UUID
from sqlalchemy.orm import Session
from onyx.chat.chat_utils import create_chat_session_from_request
from onyx.chat.models import AnswerStreamPart
from onyx.chat.process_message import handle_stream_message_objects
from onyx.configs.constants import DocumentSource
from onyx.context.search.models import SearchDoc
from onyx.db.models import ChatSession
from onyx.db.models import User
from onyx.server.query_and_chat.models import ChatSessionCreationRequest
from onyx.server.query_and_chat.models import SendMessageRequest
from onyx.server.query_and_chat.placement import Placement
from onyx.server.query_and_chat.streaming_models import AgentResponseDelta
from onyx.server.query_and_chat.streaming_models import Packet
from onyx.server.query_and_chat.streaming_models import ReasoningDelta
from tests.external_dependency_unit.mock_content_provider import MockWebContent
from tests.external_dependency_unit.mock_search_provider import MockWebSearchResult
def create_placement(
turn_index: int,
tab_index: int = 0,
sub_turn_index: int | None = None,
) -> Placement:
return Placement(
turn_index=turn_index,
tab_index=tab_index,
sub_turn_index=sub_turn_index,
)
def submit_query(
query: str, chat_session_id: UUID | None, db_session: Session, user: User
) -> Iterator[AnswerStreamPart]:
request = SendMessageRequest(
message=query,
chat_session_id=chat_session_id,
stream=True,
chat_session_info=(
ChatSessionCreationRequest() if chat_session_id is None else None
),
)
return handle_stream_message_objects(
new_msg_req=request,
user=user,
db_session=db_session,
)
def create_chat_session(
db_session: Session,
user: User,
) -> ChatSession:
return create_chat_session_from_request(
chat_session_request=ChatSessionCreationRequest(),
user_id=user.id,
db_session=db_session,
)
def create_packet_with_agent_response_delta(token: str, turn_index: int) -> Packet:
return Packet(
placement=create_placement(turn_index),
obj=AgentResponseDelta(
content=token,
),
)
def create_packet_with_reasoning_delta(token: str, turn_index: int) -> Packet:
return Packet(
placement=create_placement(turn_index),
obj=ReasoningDelta(
reasoning=token,
),
)
def create_web_search_doc(
semantic_identifier: str,
link: str,
blurb: str,
) -> SearchDoc:
return SearchDoc(
document_id=f"WEB_SEARCH_DOC_{link}",
chunk_ind=0,
semantic_identifier=semantic_identifier,
link=link,
blurb=blurb,
source_type=DocumentSource.WEB,
boost=1,
hidden=False,
metadata={},
match_highlights=[],
)
def mock_web_search_result_to_search_doc(result: MockWebSearchResult) -> SearchDoc:
return create_web_search_doc(
semantic_identifier=result.title,
link=result.link,
blurb=result.snippet,
)
def mock_web_content_to_search_doc(content: MockWebContent) -> SearchDoc:
return create_web_search_doc(
semantic_identifier=content.title,
link=content.url,
blurb=content.title,
)
def tokenise(text: str) -> list[str]:
return [(token + " ") for token in text.split(" ")]

View File

@@ -0,0 +1,59 @@
import abc
from collections.abc import Generator
from collections.abc import Sequence
from contextlib import contextmanager
from unittest.mock import patch
from pydantic import BaseModel
from onyx.tools.tool_implementations.open_url.models import WebContent
from onyx.tools.tool_implementations.open_url.models import WebContentProvider
class MockWebContent(BaseModel):
title: str
url: str
content: str
def to_web_content(self) -> WebContent:
return WebContent(
title=self.title,
link=self.url,
full_content=self.content,
published_date=None,
scrape_successful=True,
)
class ContentProviderController(abc.ABC):
@abc.abstractmethod
def add_content(self, content: MockWebContent) -> None:
raise NotImplementedError
class MockContentProvider(WebContentProvider, ContentProviderController):
def __init__(self) -> None:
self._contents: list[MockWebContent] = []
def add_content(self, web_content: MockWebContent) -> None:
self._contents.append(web_content)
def contents(self, urls: Sequence[str]) -> list[WebContent]:
filtered_contents = list(
filter(lambda web_content: web_content.url in urls, self._contents)
)
return list(
map(lambda web_content: web_content.to_web_content(), filtered_contents)
)
@contextmanager
def use_mock_content_provider() -> Generator[ContentProviderController, None, None]:
content_provider = MockContentProvider()
with patch(
"onyx.tools.tool_implementations.open_url.open_url_tool.get_default_content_provider",
return_value=content_provider,
):
yield content_provider

View File

@@ -0,0 +1,130 @@
import abc
import asyncio
import concurrent.futures
import time
from collections.abc import Generator
from contextlib import contextmanager
from datetime import datetime
from typing import Any
from unittest.mock import patch
from litellm.types.utils import ImageObject
from litellm.types.utils import ImageResponse
from onyx.image_gen.interfaces import ImageGenerationProvider
from onyx.image_gen.interfaces import ImageGenerationProviderCredentials
from onyx.llm.interfaces import LLMConfig
class ImageGenerationProviderController(abc.ABC):
@abc.abstractmethod
def add_image(
self,
data: str,
delay: float = 0.0,
) -> None:
raise NotImplementedError
class MockImageGenerationProvider(
ImageGenerationProvider, ImageGenerationProviderController
):
def __init__(self) -> None:
self._images: list[str] = []
self._delays: list[float] = []
def add_image(
self,
data: str,
delay: float = 0.0,
) -> None:
self._images.append(data)
self._delays.append(delay)
@classmethod
def validate_credentials(
cls,
credentials: ImageGenerationProviderCredentials,
) -> bool:
return True
@classmethod
def _build_from_credentials(
cls,
_: ImageGenerationProviderCredentials,
) -> ImageGenerationProvider:
return cls()
def generate_image(
self,
prompt: str,
model: str,
size: str,
n: int,
quality: str | None = None,
**kwargs: Any,
) -> ImageResponse:
image_data = self._images.pop(0)
delay = self._delays.pop(0)
if delay > 0.0:
try:
asyncio.get_running_loop()
# Event loop is running - run sleep in executor to avoid blocking the event loop
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(time.sleep, delay)
future.result()
except RuntimeError:
# No running event loop, use regular thread sleep
time.sleep(delay)
return ImageResponse(
created=int(datetime.now().timestamp()),
data=[
ImageObject(
b64_json=image_data,
revised_prompt=prompt,
)
],
)
def _create_mock_image_generation_llm_config() -> LLMConfig:
"""Create a mock LLMConfig for image generation."""
return LLMConfig(
model_provider="openai",
model_name="gpt-image-1",
temperature=0.0,
api_key="mock-api-key",
api_base=None,
api_version=None,
deployment_name=None,
max_input_tokens=100000,
custom_config=None,
)
@contextmanager
def use_mock_image_generation_provider() -> (
Generator[ImageGenerationProviderController, None, None]
):
image_gen_provider = MockImageGenerationProvider()
with (
# Mock the image generation provider factory
patch(
"onyx.tools.tool_implementations.images.image_generation_tool.get_image_generation_provider",
return_value=image_gen_provider,
),
# Mock is_available to return True so the tool is registered
patch(
"onyx.tools.tool_implementations.images.image_generation_tool.ImageGenerationTool.is_available",
return_value=True,
),
# Mock the config lookup in tool_constructor to return a valid LLMConfig
patch(
"onyx.tools.tool_constructor._get_image_generation_config",
return_value=_create_mock_image_generation_llm_config(),
),
):
yield image_gen_provider

View File

@@ -6,40 +6,275 @@ import time
from collections.abc import Generator
from collections.abc import Iterator
from contextlib import contextmanager
from enum import Enum
from typing import Any
from typing import cast
from typing import Generic
from typing import Literal
from typing import TypeVar
from unittest.mock import patch
from pydantic import BaseModel
from onyx.llm.interfaces import LanguageModelInput
from onyx.llm.interfaces import LLM
from onyx.llm.interfaces import LLMConfig
from onyx.llm.interfaces import LLMUserIdentity
from onyx.llm.interfaces import ReasoningEffort
from onyx.llm.interfaces import ToolChoiceOptions
from onyx.llm.model_response import ChatCompletionDeltaToolCall
from onyx.llm.model_response import Delta
from onyx.llm.model_response import FunctionCall
from onyx.llm.model_response import ModelResponse
from onyx.llm.model_response import ModelResponseStream
from onyx.llm.model_response import StreamingChoice
T = TypeVar("T")
class LLMResponseType(str, Enum):
REASONING = "reasoning"
ANSWER = "answer"
TOOL_CALL = "tool_call"
class LLMResponse(abc.ABC, BaseModel):
type: str = ""
@abc.abstractmethod
def num_tokens(self) -> int:
raise NotImplementedError
class LLMReasoningResponse(LLMResponse):
type: Literal["reasoning"] = LLMResponseType.REASONING.value
reasoning_tokens: list[str]
def num_tokens(self) -> int:
return len(self.reasoning_tokens)
class LLMAnswerResponse(LLMResponse):
type: Literal["answer"] = LLMResponseType.ANSWER.value
answer_tokens: list[str]
def num_tokens(self) -> int:
return len(self.answer_tokens)
class LLMToolCallResponse(LLMResponse):
type: Literal["tool_call"] = LLMResponseType.TOOL_CALL.value
tool_name: str
tool_call_id: str
tool_call_argument_tokens: list[str]
def num_tokens(self) -> int:
return (
len(self.tool_call_argument_tokens) + 1
) # +1 for the tool_call_id and tool_name
class StreamItem(BaseModel):
"""Represents a single item in the mock LLM stream with its type."""
response_type: LLMResponseType
data: Any
def _response_to_stream_items(response: LLMResponse) -> list[StreamItem]:
match LLMResponseType(response.type):
case LLMResponseType.REASONING:
response = cast(LLMReasoningResponse, response)
return [
StreamItem(
response_type=LLMResponseType.REASONING,
data=token,
)
for token in response.reasoning_tokens
]
case LLMResponseType.ANSWER:
response = cast(LLMAnswerResponse, response)
return [
StreamItem(
response_type=LLMResponseType.ANSWER,
data=token,
)
for token in response.answer_tokens
]
case LLMResponseType.TOOL_CALL:
response = cast(LLMToolCallResponse, response)
return [
StreamItem(
response_type=LLMResponseType.TOOL_CALL,
data={
"tool_call_id": response.tool_call_id,
"tool_name": response.tool_name,
"arguments": None,
},
)
] + [
StreamItem(
response_type=LLMResponseType.TOOL_CALL,
data={
"tool_call_id": None,
"tool_name": None,
"arguments": token,
},
)
for token in response.tool_call_argument_tokens
]
case _:
raise ValueError(f"Unknown response type: {response.type}")
def create_delta_from_stream_item(item: StreamItem) -> Delta:
response_type = item.response_type
data = item.data
if response_type == LLMResponseType.REASONING:
return Delta(reasoning_content=data)
elif response_type == LLMResponseType.ANSWER:
return Delta(content=data)
elif response_type == LLMResponseType.TOOL_CALL:
# Handle grouped tool calls (list) vs single tool call (dict)
if isinstance(data, list):
# Multiple tool calls emitted together in the same tick
tool_calls = []
for tc_data in data:
if tc_data["tool_call_id"] is not None:
tool_calls.append(
ChatCompletionDeltaToolCall(
id=tc_data["tool_call_id"],
index=tc_data["index"],
function=FunctionCall(
arguments="",
name=tc_data["tool_name"],
),
)
)
else:
tool_calls.append(
ChatCompletionDeltaToolCall(
index=tc_data["index"],
id=None,
function=FunctionCall(
arguments=tc_data["arguments"],
name=None,
),
)
)
return Delta(tool_calls=tool_calls)
else:
# Single tool call (original behavior)
# First tick has tool_call_id and tool_name, subsequent ticks have arguments
if data["tool_call_id"] is not None:
return Delta(
tool_calls=[
ChatCompletionDeltaToolCall(
id=data["tool_call_id"],
function=FunctionCall(
name=data["tool_name"],
arguments="",
),
)
]
)
else:
return Delta(
tool_calls=[
ChatCompletionDeltaToolCall(
id=None,
function=FunctionCall(
name=None,
arguments=data["arguments"],
),
)
]
)
else:
raise ValueError(f"Unknown response type: {response_type}")
class MockLLMController(abc.ABC):
@abc.abstractmethod
def set_response(self, response_tokens: list[str]) -> None:
def add_response(self, response: LLMResponse) -> None:
"""Add a response to the current stream."""
raise NotImplementedError
@abc.abstractmethod
def add_responses_together(self, *responses: LLMResponse) -> None:
"""Add multiple responses that should be emitted together in the same tick."""
raise NotImplementedError
@abc.abstractmethod
def forward(self, n: int) -> None:
"""Forward the stream by n tokens."""
raise NotImplementedError
@abc.abstractmethod
def forward_till_end(self) -> None:
"""Forward the stream until the end."""
raise NotImplementedError
@abc.abstractmethod
def set_max_timeout(self, timeout: float = 5.0) -> None:
raise NotImplementedError
class MockLLM(LLM, MockLLMController):
def __init__(self) -> None:
self.stream_controller: SyncStreamController | None = None
self.stream_controller = SyncStreamController[StreamItem]()
def set_response(self, response_tokens: list[str]) -> None:
self.stream_controller = SyncStreamController(response_tokens)
def add_response(self, response: LLMResponse) -> None:
items = _response_to_stream_items(response)
self.stream_controller.queue_items(items)
def add_responses_together(self, *responses: LLMResponse) -> None:
"""Add multiple responses that should be emitted together in the same tick.
Currently only supports multiple tool call responses being grouped together.
The initial tool call info (id, name) for all tool calls will be emitted
in a single delta, followed by argument tokens for each tool call.
"""
tool_calls = [r for r in responses if r.type == LLMResponseType.TOOL_CALL]
if len(tool_calls) != len(responses):
raise ValueError(
"add_responses_together currently only supports "
"multiple tool call responses"
)
# Create combined first item with all tool call initial info
combined_data = [
{
"index": idx,
"tool_call_id": cast(LLMToolCallResponse, tc).tool_call_id,
"tool_name": cast(LLMToolCallResponse, tc).tool_name,
"arguments": None,
}
for idx, tc in enumerate(tool_calls)
]
combined_item = StreamItem(
response_type=LLMResponseType.TOOL_CALL,
data=combined_data,
)
self.stream_controller.queue_items([combined_item])
# Add argument tokens for each tool call with their index
for idx, tc in enumerate(tool_calls):
tc = cast(LLMToolCallResponse, tc)
for token in tc.tool_call_argument_tokens:
item = StreamItem(
response_type=LLMResponseType.TOOL_CALL,
data=[
{
"index": idx,
"tool_call_id": None,
"tool_name": None,
"arguments": token,
}
],
)
self.stream_controller.queue_items([item])
def forward(self, n: int) -> None:
if self.stream_controller:
@@ -53,6 +288,9 @@ class MockLLM(LLM, MockLLMController):
else:
raise ValueError("No response set")
def set_max_timeout(self, timeout: float = 5.0) -> None:
self.stream_controller.timeout = timeout
@property
def config(self) -> LLMConfig:
return LLMConfig(
@@ -89,16 +327,14 @@ class MockLLM(LLM, MockLLMController):
if not self.stream_controller:
return
for idx, token in enumerate(self.stream_controller):
for idx, item in enumerate(self.stream_controller):
yield ModelResponseStream(
id="chatcmp-123",
created="1",
choice=StreamingChoice(
finish_reason=None,
index=idx,
delta=Delta(
content=token,
),
index=0, # Choice index should stay at 0 for all items in the same stream
delta=create_delta_from_stream_item(item),
),
usage=None,
)
@@ -108,18 +344,22 @@ class StreamTimeoutError(Exception):
"""Raised when the stream controller times out waiting for tokens."""
class SyncStreamController:
def __init__(self, tokens: list[str], timeout: float = 5.0) -> None:
self.tokens = tokens
class SyncStreamController(Generic[T]):
def __init__(self, items: list[T] | None = None, timeout: float = 5.0) -> None:
self.items = items if items is not None else []
self.position = 0
self.pending: list[int] = [] # The indices of the tokens that are pending
self.timeout = timeout # Maximum time to wait for tokens before failing
self._has_pending = threading.Event()
def queue_items(self, new_items: list[T]) -> None:
"""Queue additional tokens to the stream (for chaining responses like reasoning + tool calls)."""
self.items.extend(new_items)
def forward(self, n: int) -> None:
"""Queue the next n tokens to be yielded"""
end = min(self.position + n, len(self.tokens))
end = min(self.position + n, len(self.items))
self.pending.extend(range(self.position, end))
self.position = end
@@ -127,29 +367,29 @@ class SyncStreamController:
self._has_pending.set()
def forward_till_end(self) -> None:
self.forward(len(self.tokens) - self.position)
self.forward(len(self.items) - self.position)
@property
def is_done(self) -> bool:
return self.position >= len(self.tokens) and not self.pending
return self.position >= len(self.items) and not self.pending
def __iter__(self) -> SyncStreamController:
def __iter__(self) -> SyncStreamController[T]:
return self
def __next__(self) -> str:
def __next__(self) -> T:
start_time = time.monotonic()
while not self.is_done:
if self.pending:
token_idx = self.pending.pop(0)
item_idx = self.pending.pop(0)
if not self.pending:
self._has_pending.clear()
return self.tokens[token_idx]
return self.items[item_idx]
elapsed = time.monotonic() - start_time
if elapsed >= self.timeout:
raise StreamTimeoutError(
f"Stream controller timed out after {self.timeout}s waiting for tokens. "
f"Position: {self.position}/{len(self.tokens)}, Pending: {len(self.pending)}"
f"Position: {self.position}/{len(self.items)}, Pending: {len(self.pending)}"
)
self._has_pending.wait(timeout=0.1)

View File

@@ -0,0 +1,183 @@
from collections.abc import Callable
from collections.abc import Generator
from contextlib import contextmanager
from typing import Any
from unittest.mock import patch
from pydantic import BaseModel
from sqlalchemy.orm import Session
from onyx.configs.constants import DocumentSource
from onyx.context.search.models import ChunkSearchRequest
from onyx.context.search.models import InferenceChunk
from onyx.context.search.models import SearchDoc
from onyx.db.models import Persona
from onyx.db.models import User
from onyx.document_index.interfaces import DocumentIndex
from onyx.llm.interfaces import LLM
def run_functions_tuples_sequential(
functions_with_args: list[tuple[Callable, tuple]],
allow_failures: bool = False,
max_workers: int | None = None,
timeout: float | None = None,
timeout_callback: Callable | None = None,
) -> list[Any]:
"""
A sequential replacement for run_functions_tuples_in_parallel.
Useful in tests to make parallel tool calls deterministic.
"""
results = []
for func, args in functions_with_args:
try:
results.append(func(*args))
except Exception:
if allow_failures:
results.append(None)
else:
raise
return results
class MockInternalSearchResult(BaseModel):
document_id: str
source_type: DocumentSource
semantic_identifier: str
chunk_ind: int
def to_inference_chunk(self) -> InferenceChunk:
return InferenceChunk(
document_id=f"{self.source_type.value.upper()}_{self.document_id}",
source_type=self.source_type,
semantic_identifier=self.semantic_identifier,
title=self.semantic_identifier,
chunk_id=self.chunk_ind,
blurb="",
content="",
source_links=None,
image_file_id=None,
section_continuation=False,
boost=0,
score=1.0,
hidden=False,
metadata={},
match_highlights=[],
doc_summary="",
chunk_context="",
updated_at=None,
)
def to_search_doc(self) -> SearchDoc:
return SearchDoc(
document_id=f"{self.source_type.value.upper()}_{self.document_id}",
chunk_ind=self.chunk_ind,
semantic_identifier=self.semantic_identifier,
link=None,
blurb="",
source_type=self.source_type,
boost=0,
hidden=False,
metadata={},
score=1.0,
match_highlights=[],
updated_at=None,
)
class SearchPipelineController:
def __init__(self) -> None:
self.search_results: dict[str, list[MockInternalSearchResult]] = {}
def add_search_results(
self, query: str, results: list[MockInternalSearchResult]
) -> None:
self.search_results[query] = results
def get_search_results(self, query: str) -> list[InferenceChunk]:
return [
result.to_inference_chunk() for result in self.search_results.get(query, [])
]
@contextmanager
def use_mock_search_pipeline(
connectors: list[DocumentSource],
) -> Generator[SearchPipelineController, None, None]:
"""Mock the search pipeline and connector availability.
Args:
connectors: List of DocumentSource types to pretend are available.
Pass an empty list to simulate no connectors.
"""
controller = SearchPipelineController()
def mock_check_connectors_exist(db_session: Session) -> bool:
return len(connectors) > 0
def mock_check_federated_connectors_exist(db_session: Session) -> bool:
# For now, federated connectors are not mocked as available
return False
def mock_check_user_files_exist(db_session: Session) -> bool:
# For now, user files are not mocked as available
return False
def mock_fetch_unique_document_sources(db_session: Session) -> list[DocumentSource]:
return connectors
def override_search_pipeline(
chunk_search_request: ChunkSearchRequest,
document_index: DocumentIndex,
user: User | None,
persona: Persona | None,
db_session: Session,
auto_detect_filters: bool = False,
llm: LLM | None = None,
project_id: int | None = None,
) -> list[InferenceChunk]:
return controller.get_search_results(chunk_search_request.query)
with (
patch(
"onyx.tools.tool_implementations.search.search_tool.search_pipeline",
new=override_search_pipeline,
),
patch(
"onyx.tools.tool_implementations.search.search_tool.check_connectors_exist",
new=mock_check_connectors_exist,
),
patch(
"onyx.tools.tool_implementations.search.search_tool.check_federated_connectors_exist",
new=mock_check_federated_connectors_exist,
),
patch(
"onyx.tools.tool_implementations.search.search_tool.semantic_query_rephrase",
return_value="",
),
patch(
"onyx.tools.tool_implementations.search.search_tool.keyword_query_expansion",
return_value=[],
),
patch(
"onyx.tools.tool_runner.run_functions_tuples_in_parallel",
new=run_functions_tuples_sequential,
),
patch(
"onyx.db.connector.check_connectors_exist",
new=mock_check_connectors_exist,
),
patch(
"onyx.db.connector.check_federated_connectors_exist",
new=mock_check_federated_connectors_exist,
),
patch(
"onyx.db.connector.check_user_files_exist",
new=mock_check_user_files_exist,
),
patch(
"onyx.db.connector.fetch_unique_document_sources",
new=mock_fetch_unique_document_sources,
),
):
yield controller

View File

@@ -0,0 +1,97 @@
import abc
from collections import defaultdict
from collections.abc import Generator
from collections.abc import Sequence
from contextlib import contextmanager
from unittest.mock import patch
from pydantic import BaseModel
from sqlalchemy.orm import Session
from onyx.db.models import InternetSearchProvider
from onyx.db.web_search import fetch_web_search_provider_by_name
from onyx.tools.tool_implementations.web_search.models import WebSearchProvider
from onyx.tools.tool_implementations.web_search.models import WebSearchResult
from shared_configs.enums import WebSearchProviderType
class MockWebSearchResult(BaseModel):
title: str
link: str
snippet: str
def to_web_search_result(self) -> WebSearchResult:
return WebSearchResult(
title=self.title,
link=self.link,
snippet=self.snippet,
author=None,
published_date=None,
)
class WebProviderController(abc.ABC):
@abc.abstractmethod
def add_results(self, query: str, results: list[MockWebSearchResult]) -> None:
raise NotImplementedError
class MockWebProvider(WebSearchProvider, WebProviderController):
def __init__(self) -> None:
self._results: dict[str, list[MockWebSearchResult]] = defaultdict(list)
def add_results(self, query: str, results: list[MockWebSearchResult]) -> None:
self._results[query] = results
def search(self, query: str) -> Sequence[WebSearchResult]:
return list(
map(lambda result: result.to_web_search_result(), self._results[query])
)
def test_connection(self) -> dict[str, str]:
return {}
def add_web_provider_to_db(db_session: Session) -> None:
# Write a provider to the database
if fetch_web_search_provider_by_name(name="Test Provider 2", db_session=db_session):
return
provider = InternetSearchProvider(
name="Test Provider 2",
provider_type=WebSearchProviderType.EXA.value,
api_key="test-api-key",
config={},
is_active=True,
)
db_session.add(provider)
db_session.commit()
def delete_web_provider_from_db(db_session: Session) -> None:
provider = fetch_web_search_provider_by_name(
name="Test Provider 2", db_session=db_session
)
if provider is not None:
db_session.delete(provider)
db_session.commit()
@contextmanager
def use_mock_web_provider(
db_session: Session,
) -> Generator[WebProviderController, None, None]:
web_provider = MockWebProvider()
# Write the tool to the database
add_web_provider_to_db(db_session)
# override the build function
with patch(
"onyx.tools.tool_implementations.web_search.web_search_tool.build_search_provider_from_config",
return_value=web_provider,
):
yield web_provider
delete_web_provider_from_db(db_session)

21
extensions/chrome/LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 DanswerAI, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,30 @@
# Onyx Chrome Extension
The Onyx chrome extension lets you research, create, and automate with LLMs powered by your team's unique knowledge. Just hit Ctrl + O on Mac or Alt + O on Windows to instantly access Onyx in your browser:
💡 Know what your company knows, instantly with the Onyx sidebar
💬 Chat: Onyx provides a natural language chat interface as the main way of interacting with the features.
🌎 Internal Search: Ask questions and get answers from all your team's knowledge, powered by Onyx's 50+ connectors to all the tools your team uses
🚀 With a simple Ctrl + O on Mac or Alt + O on Windows - instantly summarize information from any work application
⚡️ Get quick access to the work resources you need.
🆕 Onyx new tab page puts all of your companys knowledge at your fingertips
🤖 Access custom AI Agents for unique use cases, and give them access to tools to take action.
Onyx connects with dozens of popular workplace apps like Google Drive, Jira, Confluence, Slack, and more. Use this extension if you have an account created by your team admin.
## Installation
For Onyx Cloud Users, please visit the Chrome Plugin Store (pending approval still)
## Development
- Load unpacked extension in your browser
- Modify files in `src` directory
- Refresh extension in Chrome
## Contributing
Submit issues or pull requests for improvements

View File

@@ -0,0 +1,70 @@
{
"manifest_version": 3,
"name": "Onyx",
"version": "1.0",
"description": "Onyx lets you research, create, and automate with LLMs powered by your team's unique knowledge",
"permissions": [
"sidePanel",
"storage",
"activeTab",
"tabs"
],
"host_permissions": ["<all_urls>"],
"background": {
"service_worker": "service_worker.js",
"type": "module"
},
"action": {
"default_icon": {
"16": "public/icon16.png",
"48": "public/icon48.png",
"128": "public/icon128.png"
},
"default_popup": "src/pages/popup.html"
},
"icons": {
"16": "public/icon16.png",
"48": "public/icon48.png",
"128": "public/icon128.png"
},
"options_page": "src/pages/options.html",
"chrome_url_overrides": {
"newtab": "src/pages/onyx_home.html"
},
"commands": {
"toggleNewTabOverride": {
"suggested_key": {
"default": "Ctrl+Shift+O",
"mac": "Command+Shift+O"
},
"description": "Toggle Onyx New Tab Override"
},
"openSidePanel": {
"suggested_key": {
"default": "Ctrl+O",
"windows": "Alt+O",
"mac": "MacCtrl+O"
},
"description": "Open Onyx Side Panel"
}
},
"side_panel": {
"default_path": "src/pages/panel.html"
},
"omnibox": {
"keyword": "onyx"
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["src/utils/selection-icon.js"],
"css": ["src/styles/selection-icon.css"]
}
],
"web_accessible_resources": [
{
"resources": ["public/icon32.png"],
"matches": ["<all_urls>"]
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 551 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

View File

@@ -0,0 +1,276 @@
import {
DEFAULT_ONYX_DOMAIN,
CHROME_SPECIFIC_STORAGE_KEYS,
ACTIONS,
SIDE_PANEL_PATH,
} from "./src/utils/constants.js";
// Track side panel state per window
const sidePanelOpenState = new Map();
// Open welcome page on first install
chrome.runtime.onInstalled.addListener((details) => {
if (details.reason === "install") {
chrome.storage.local.get(
{ [CHROME_SPECIFIC_STORAGE_KEYS.ONBOARDING_COMPLETE]: false },
(result) => {
if (!result[CHROME_SPECIFIC_STORAGE_KEYS.ONBOARDING_COMPLETE]) {
chrome.tabs.create({ url: "src/pages/welcome.html" });
}
},
);
}
});
async function setupSidePanel() {
if (chrome.sidePanel) {
try {
// Don't auto-open side panel on action click since we have a popup menu
await chrome.sidePanel.setPanelBehavior({
openPanelOnActionClick: false,
});
} catch (error) {
console.error("Error setting up side panel:", error);
}
}
}
async function openSidePanel(tabId) {
try {
await chrome.sidePanel.open({ tabId });
} catch (error) {
console.error("Error opening side panel:", error);
}
}
async function sendToOnyx(info, tab) {
const selectedText = encodeURIComponent(info.selectionText);
const currentUrl = encodeURIComponent(tab.url);
try {
const result = await chrome.storage.local.get({
[CHROME_SPECIFIC_STORAGE_KEYS.ONYX_DOMAIN]: DEFAULT_ONYX_DOMAIN,
});
const url = `${
result[CHROME_SPECIFIC_STORAGE_KEYS.ONYX_DOMAIN]
}${SIDE_PANEL_PATH}?user-prompt=${selectedText}`;
await openSidePanel(tab.id);
chrome.runtime.sendMessage({
action: ACTIONS.OPEN_SIDE_PANEL_WITH_INPUT,
url: url,
pageUrl: tab.url,
});
} catch (error) {
console.error("Error sending to Onyx:", error);
}
}
async function toggleNewTabOverride() {
try {
const result = await chrome.storage.local.get(
CHROME_SPECIFIC_STORAGE_KEYS.USE_ONYX_AS_DEFAULT_NEW_TAB,
);
const newValue =
!result[CHROME_SPECIFIC_STORAGE_KEYS.USE_ONYX_AS_DEFAULT_NEW_TAB];
await chrome.storage.local.set({
[CHROME_SPECIFIC_STORAGE_KEYS.USE_ONYX_AS_DEFAULT_NEW_TAB]: newValue,
});
chrome.notifications.create({
type: "basic",
iconUrl: "icon.png",
title: "Onyx New Tab",
message: `New Tab Override ${newValue ? "enabled" : "disabled"}`,
});
// Send a message to inform all tabs about the change
chrome.tabs.query({}, (tabs) => {
tabs.forEach((tab) => {
chrome.tabs.sendMessage(tab.id, {
action: "newTabOverrideToggled",
value: newValue,
});
});
});
} catch (error) {
console.error("Error toggling new tab override:", error);
}
}
// Note: This listener won't fire when a popup is defined in manifest.json
// The popup will show instead. This is kept as a fallback if popup is removed.
chrome.action.onClicked.addListener((tab) => {
openSidePanel(tab.id);
});
chrome.commands.onCommand.addListener(async (command) => {
if (command === ACTIONS.SEND_TO_ONYX) {
try {
const [tab] = await chrome.tabs.query({
active: true,
lastFocusedWindow: true,
});
if (tab) {
const response = await chrome.tabs.sendMessage(tab.id, {
action: ACTIONS.GET_SELECTED_TEXT,
});
const selectedText = response?.selectedText || "";
sendToOnyx({ selectionText: selectedText }, tab);
}
} catch (error) {
console.error("Error sending to Onyx:", error);
}
} else if (command === ACTIONS.TOGGLE_NEW_TAB_OVERRIDE) {
toggleNewTabOverride();
} else if (command === ACTIONS.CLOSE_SIDE_PANEL) {
try {
await chrome.sidePanel.hide();
} catch (error) {
console.error("Error closing side panel via command:", error);
}
} else if (command === ACTIONS.OPEN_SIDE_PANEL) {
chrome.tabs.query({ active: true, lastFocusedWindow: true }, (tabs) => {
if (tabs && tabs.length > 0) {
const tab = tabs[0];
const windowId = tab.windowId;
const isOpen = sidePanelOpenState.get(windowId) || false;
if (isOpen) {
chrome.sidePanel.setOptions({ enabled: false }, () => {
chrome.sidePanel.setOptions({ enabled: true });
sidePanelOpenState.set(windowId, false);
});
} else {
chrome.sidePanel.open({ tabId: tab.id });
sidePanelOpenState.set(windowId, true);
}
}
});
return;
} else {
console.log("Unhandled command:", command);
}
});
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
if (request.action === ACTIONS.GET_CURRENT_ONYX_DOMAIN) {
chrome.storage.local.get(
{ [CHROME_SPECIFIC_STORAGE_KEYS.ONYX_DOMAIN]: DEFAULT_ONYX_DOMAIN },
(result) => {
sendResponse({
[CHROME_SPECIFIC_STORAGE_KEYS.ONYX_DOMAIN]:
result[CHROME_SPECIFIC_STORAGE_KEYS.ONYX_DOMAIN],
});
},
);
return true;
}
if (request.action === ACTIONS.CLOSE_SIDE_PANEL) {
closeSidePanel();
chrome.storage.local.get(
{ [CHROME_SPECIFIC_STORAGE_KEYS.ONYX_DOMAIN]: DEFAULT_ONYX_DOMAIN },
(result) => {
chrome.tabs.create({
url: `${result[CHROME_SPECIFIC_STORAGE_KEYS.ONYX_DOMAIN]}/auth/login`,
active: true,
});
},
);
return true;
}
if (request.action === ACTIONS.OPEN_SIDE_PANEL_WITH_INPUT) {
const { selectedText, pageUrl } = request;
const tabId = sender.tab?.id;
const windowId = sender.tab?.windowId;
if (tabId && windowId) {
chrome.storage.local.get(
{ [CHROME_SPECIFIC_STORAGE_KEYS.ONYX_DOMAIN]: DEFAULT_ONYX_DOMAIN },
(result) => {
const encodedText = encodeURIComponent(selectedText);
const onyxDomain = result[CHROME_SPECIFIC_STORAGE_KEYS.ONYX_DOMAIN];
const url = `${onyxDomain}${SIDE_PANEL_PATH}?user-prompt=${encodedText}`;
chrome.storage.session.set({
pendingInput: {
url: url,
pageUrl: pageUrl,
timestamp: Date.now(),
},
});
chrome.sidePanel
.open({ windowId })
.then(() => {
chrome.runtime.sendMessage({
action: ACTIONS.OPEN_ONYX_WITH_INPUT,
url: url,
pageUrl: pageUrl,
});
})
.catch((error) => {
console.error(
"[Onyx SW] Error opening side panel with text:",
error,
);
});
},
);
} else {
console.error("[Onyx SW] Missing tabId or windowId");
}
return true;
}
});
chrome.storage.onChanged.addListener((changes, namespace) => {
if (
namespace === "local" &&
changes[CHROME_SPECIFIC_STORAGE_KEYS.USE_ONYX_AS_DEFAULT_NEW_TAB]
) {
const newValue =
changes[CHROME_SPECIFIC_STORAGE_KEYS.USE_ONYX_AS_DEFAULT_NEW_TAB]
.newValue;
if (newValue === false) {
chrome.runtime.openOptionsPage();
}
}
});
chrome.windows.onRemoved.addListener((windowId) => {
sidePanelOpenState.delete(windowId);
});
chrome.omnibox.setDefaultSuggestion({
description: 'Search Onyx for "%s"',
});
chrome.omnibox.onInputEntered.addListener(async (text) => {
try {
const result = await chrome.storage.local.get({
[CHROME_SPECIFIC_STORAGE_KEYS.ONYX_DOMAIN]: DEFAULT_ONYX_DOMAIN,
});
const domain = result[CHROME_SPECIFIC_STORAGE_KEYS.ONYX_DOMAIN];
const searchUrl = `${domain}/chat?user-prompt=${encodeURIComponent(text)}`;
chrome.tabs.update({ url: searchUrl });
} catch (error) {
console.error("Error handling omnibox search:", error);
}
});
chrome.omnibox.onInputChanged.addListener((text, suggest) => {
if (text.trim()) {
suggest([
{
content: text,
description: `Search Onyx for "<match>${text}</match>"`,
},
]);
}
});
setupSidePanel();

BIN
extensions/chrome/src/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,76 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Permissions-Policy" content="clipboard-write=(self)" />
<title>Onyx Home</title>
<link rel="stylesheet" href="../styles/shared.css" />
<style>
body,
html {
margin: 0;
padding: 0;
width: 100%;
height: 100vh;
overflow: hidden;
}
@media (prefers-color-scheme: dark) {
html,
body {
background-color: #000;
}
}
@media (prefers-color-scheme: light) {
html,
body {
background-color: #f6f6f6;
}
}
#background {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
transition: opacity 0.5s ease-in-out;
}
#content {
position: relative;
width: 100%;
height: 100%;
opacity: 0;
transition: opacity 0.5s ease-in-out;
}
iframe {
border: none;
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
visibility: hidden;
}
</style>
</head>
<body>
<div id="background"></div>
<div id="content">
<iframe
id="onyx-iframe"
allowfullscreen
allow="clipboard-read; clipboard-write"
></iframe>
</div>
<script src="onyx_home.js" type="module"></script>
</body>
</html>

View File

@@ -0,0 +1,248 @@
import {
CHROME_MESSAGE,
CHROME_SPECIFIC_STORAGE_KEYS,
WEB_MESSAGE,
} from "../utils/constants.js";
import {
showErrorModal,
hideErrorModal,
initErrorModal,
} from "../utils/error-modal.js";
import { getOnyxDomain } from "../utils/storage.js";
(function () {
let mainIframe = document.getElementById("onyx-iframe");
let preloadedIframe = null;
const background = document.getElementById("background");
const content = document.getElementById("content");
const DEFAULT_LIGHT_BACKGROUND_IMAGE =
"https://images.unsplash.com/photo-1692520883599-d543cfe6d43d?q=80&w=2666&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D";
const DEFAULT_DARK_BACKGROUND_IMAGE =
"https://images.unsplash.com/photo-1692520883599-d543cfe6d43d?q=80&w=2666&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D";
let iframeLoadTimeout;
let iframeLoaded = false;
initErrorModal();
async function preloadChatInterface() {
preloadedIframe = document.createElement("iframe");
const domain = await getOnyxDomain();
preloadedIframe.src = domain + "/chat";
preloadedIframe.style.opacity = "0";
preloadedIframe.style.visibility = "hidden";
preloadedIframe.style.transition = "opacity 0.3s ease-in";
preloadedIframe.style.border = "none";
preloadedIframe.style.width = "100%";
preloadedIframe.style.height = "100%";
preloadedIframe.style.position = "absolute";
preloadedIframe.style.top = "0";
preloadedIframe.style.left = "0";
preloadedIframe.style.zIndex = "1";
content.appendChild(preloadedIframe);
}
function setIframeSrc(url) {
mainIframe.src = url;
startIframeLoadTimeout();
iframeLoaded = false;
}
function startIframeLoadTimeout() {
clearTimeout(iframeLoadTimeout);
iframeLoadTimeout = setTimeout(() => {
if (!iframeLoaded) {
try {
if (
mainIframe.contentWindow.location.pathname.includes("/auth/login")
) {
showLoginPage();
} else {
showErrorModal(mainIframe.src);
}
} catch (error) {
showErrorModal(mainIframe.src);
}
}
}, 2500);
}
function showLoginPage() {
background.style.opacity = "0";
mainIframe.style.opacity = "1";
mainIframe.style.visibility = "visible";
content.style.opacity = "1";
hideErrorModal();
}
function setTheme(theme, customBackgroundImage) {
const imageUrl =
customBackgroundImage ||
(theme === "dark"
? DEFAULT_DARK_BACKGROUND_IMAGE
: DEFAULT_LIGHT_BACKGROUND_IMAGE);
background.style.backgroundImage = `url('${imageUrl}')`;
}
function fadeInContent() {
content.style.transition = "opacity 0.5s ease-in";
mainIframe.style.transition = "opacity 0.5s ease-in";
content.style.opacity = "0";
mainIframe.style.opacity = "0";
mainIframe.style.visibility = "visible";
requestAnimationFrame(() => {
content.style.opacity = "1";
mainIframe.style.opacity = "1";
setTimeout(() => {
background.style.transition = "opacity 0.3s ease-out";
background.style.opacity = "0";
}, 500);
});
}
function checkOnyxPreference() {
chrome.storage.local.get(
[
CHROME_SPECIFIC_STORAGE_KEYS.USE_ONYX_AS_DEFAULT_NEW_TAB,
CHROME_SPECIFIC_STORAGE_KEYS.ONYX_DOMAIN,
],
(items) => {
let useOnyxAsDefaultNewTab =
items[CHROME_SPECIFIC_STORAGE_KEYS.USE_ONYX_AS_DEFAULT_NEW_TAB];
if (useOnyxAsDefaultNewTab === undefined) {
useOnyxAsDefaultNewTab = !!(
localStorage.getItem(
CHROME_SPECIFIC_STORAGE_KEYS.USE_ONYX_AS_DEFAULT_NEW_TAB,
) === "1"
);
chrome.storage.local.set({
[CHROME_SPECIFIC_STORAGE_KEYS.USE_ONYX_AS_DEFAULT_NEW_TAB]:
useOnyxAsDefaultNewTab,
});
}
if (!useOnyxAsDefaultNewTab) {
chrome.tabs.update({
url: "chrome://new-tab-page",
});
return;
}
setIframeSrc(
items[CHROME_SPECIFIC_STORAGE_KEYS.ONYX_DOMAIN] + "/chat/nrf",
);
},
);
}
function loadThemeAndBackground() {
chrome.storage.local.get(
[
CHROME_SPECIFIC_STORAGE_KEYS.THEME,
CHROME_SPECIFIC_STORAGE_KEYS.BACKGROUND_IMAGE,
CHROME_SPECIFIC_STORAGE_KEYS.DARK_BG_URL,
CHROME_SPECIFIC_STORAGE_KEYS.LIGHT_BG_URL,
],
function (result) {
const theme = result[CHROME_SPECIFIC_STORAGE_KEYS.THEME] || "light";
const customBackgroundImage =
result[CHROME_SPECIFIC_STORAGE_KEYS.BACKGROUND_IMAGE];
const darkBgUrl = result[CHROME_SPECIFIC_STORAGE_KEYS.DARK_BG_URL];
const lightBgUrl = result[CHROME_SPECIFIC_STORAGE_KEYS.LIGHT_BG_URL];
let backgroundImage;
if (customBackgroundImage) {
backgroundImage = customBackgroundImage;
} else if (theme === "dark" && darkBgUrl) {
backgroundImage = darkBgUrl;
} else if (theme === "light" && lightBgUrl) {
backgroundImage = lightBgUrl;
}
setTheme(theme, backgroundImage);
checkOnyxPreference();
},
);
}
function loadNewPage(newSrc) {
if (preloadedIframe && preloadedIframe.contentWindow) {
preloadedIframe.contentWindow.postMessage(
{ type: WEB_MESSAGE.PAGE_CHANGE, href: newSrc },
"*",
);
} else {
console.error("Preloaded iframe not available");
}
}
function completePendingPageLoad() {
if (preloadedIframe) {
preloadedIframe.style.visibility = "visible";
preloadedIframe.style.opacity = "1";
preloadedIframe.style.zIndex = "1";
mainIframe.style.zIndex = "2";
mainIframe.style.opacity = "0";
setTimeout(() => {
if (content.contains(mainIframe)) {
content.removeChild(mainIframe);
}
mainIframe = preloadedIframe;
mainIframe.id = "onyx-iframe";
mainIframe.style.zIndex = "";
iframeLoaded = true;
clearTimeout(iframeLoadTimeout);
}, 200);
} else {
console.warn("No preloaded iframe available");
}
}
chrome.storage.onChanged.addListener(function (changes, namespace) {
if (namespace === "local" && changes.useOnyxAsDefaultNewTab) {
checkOnyxPreference();
}
});
window.addEventListener("message", function (event) {
if (event.data.type === CHROME_MESSAGE.SET_DEFAULT_NEW_TAB) {
chrome.storage.local.set({ useOnyxAsDefaultNewTab: event.data.value });
} else if (event.data.type === CHROME_MESSAGE.ONYX_APP_LOADED) {
clearTimeout(iframeLoadTimeout);
hideErrorModal();
fadeInContent();
iframeLoaded = true;
} else if (event.data.type === CHROME_MESSAGE.PREFERENCES_UPDATED) {
const { theme, backgroundUrl } = event.data.payload;
chrome.storage.local.set(
{
[CHROME_SPECIFIC_STORAGE_KEYS.THEME]: theme,
[CHROME_SPECIFIC_STORAGE_KEYS.BACKGROUND_IMAGE]: backgroundUrl,
},
() => {},
);
} else if (event.data.type === CHROME_MESSAGE.LOAD_NEW_PAGE) {
loadNewPage(event.data.href);
} else if (event.data.type === CHROME_MESSAGE.LOAD_NEW_CHAT_PAGE) {
completePendingPageLoad();
}
});
mainIframe.onload = function () {
clearTimeout(iframeLoadTimeout);
startIframeLoadTimeout();
};
mainIframe.onerror = function (error) {
showErrorModal(mainIframe.src);
};
loadThemeAndBackground();
preloadChatInterface();
})();

View File

@@ -0,0 +1,515 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Permissions-Policy" content="clipboard-write=(self)" />
<title>Onyx - Settings</title>
<link rel="stylesheet" href="../styles/shared.css" />
<style>
:root {
--background-900: #0a0a0a;
--background-800: #1a1a1a;
--text-light-05: rgba(255, 255, 255, 0.95);
--text-light-03: rgba(255, 255, 255, 0.6);
--white-10: rgba(255, 255, 255, 0.1);
--white-15: rgba(255, 255, 255, 0.15);
--white-20: rgba(255, 255, 255, 0.2);
--white-30: rgba(255, 255, 255, 0.3);
--white-40: rgba(255, 255, 255, 0.4);
--white-80: rgba(255, 255, 255, 0.8);
--black-40: rgba(0, 0, 0, 0.4);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 0;
font-family: var(--font-hanken-grotesk);
background: linear-gradient(
135deg,
var(--background-900) 0%,
var(--background-800) 100%
);
min-height: 100vh;
color: var(--text-light-05);
transition: background 0.3s ease;
}
body.light-theme {
--background-900: #f5f5f5;
--background-800: #ffffff;
--text-light-05: rgba(0, 0, 0, 0.95);
--text-light-03: rgba(0, 0, 0, 0.6);
background: linear-gradient(135deg, #f5f5f5 0%, #ffffff 100%);
}
body.light-theme .settings-panel {
background: linear-gradient(
to bottom,
rgba(255, 255, 255, 0.95),
rgba(245, 245, 245, 0.95)
);
border: 1px solid rgba(0, 0, 0, 0.1);
}
body.light-theme .settings-header {
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
}
body.light-theme .settings-icon {
background: rgba(0, 0, 0, 0.05);
}
body.light-theme .theme-toggle {
background: rgba(0, 0, 0, 0.05);
border: 1px solid rgba(0, 0, 0, 0.1);
}
body.light-theme .theme-toggle:hover {
background: rgba(0, 0, 0, 0.08);
}
body.light-theme .theme-toggle svg {
stroke: rgba(0, 0, 0, 0.95);
}
body.light-theme .settings-group {
background: rgba(0, 0, 0, 0.03);
}
body.light-theme .setting-divider {
background: rgba(0, 0, 0, 0.1);
}
body.light-theme .input-field {
border: 1px solid rgba(0, 0, 0, 0.1);
background: rgba(0, 0, 0, 0.05);
color: rgba(0, 0, 0, 0.95);
}
body.light-theme .input-field:focus {
outline: none;
border-color: rgba(0, 0, 0, 0.25);
background: rgba(0, 0, 0, 0.08);
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.05);
color: rgba(0, 0, 0, 0.95);
}
body.light-theme .status-container {
background: rgba(0, 0, 0, 0.03);
}
body.light-theme .button.secondary {
background: rgba(0, 0, 0, 0.05);
color: rgba(0, 0, 0, 0.95);
}
body.light-theme .button.secondary:hover {
background: rgba(0, 0, 0, 0.08);
}
body.light-theme .toggle-slider {
background-color: rgba(0, 0, 0, 0.15);
}
body.light-theme input:checked + .toggle-slider {
background-color: rgba(0, 0, 0, 0.3);
}
body.light-theme .toggle-slider:before {
background-color: white;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
.settings-container {
max-width: 500px;
width: 100%;
margin: 0 auto;
padding: 40px 20px;
}
.settings-panel {
background: linear-gradient(
to bottom,
rgba(10, 10, 10, 0.95),
rgba(26, 26, 26, 0.95)
);
backdrop-filter: blur(24px);
border-radius: 16px;
border: 1px solid var(--white-10);
overflow: hidden;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.settings-header {
padding: 24px;
border-bottom: 1px solid var(--white-10);
display: flex;
align-items: center;
justify-content: space-between;
background: transparent;
}
.settings-header-left {
display: flex;
align-items: center;
gap: 12px;
}
.settings-icon {
width: 40px;
height: 40px;
border-radius: 12px;
background: white;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.settings-icon img {
width: 100%;
height: 100%;
object-fit: contain;
padding: 6px;
}
.settings-title {
font-size: 20px;
font-weight: 600;
color: var(--text-light-05);
margin: 0;
}
.theme-toggle {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border-radius: 999px;
background: var(--white-10);
border: 1px solid var(--white-10);
cursor: pointer;
transition: all 0.2s;
}
.theme-toggle:hover {
background: var(--white-15);
}
.theme-toggle svg {
width: 16px;
height: 16px;
stroke: var(--text-light-05);
}
.settings-content {
padding: 24px;
}
.settings-section {
margin-bottom: 32px;
}
.settings-section:last-child {
margin-bottom: 0;
}
.section-title {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-light-03);
margin-bottom: 12px;
}
.settings-group {
background: rgba(255, 255, 255, 0.05);
border-radius: 16px;
padding: 4px;
}
.setting-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
}
.setting-row-content {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
}
.setting-label {
font-size: 14px;
font-weight: 400;
color: var(--text-light-05);
}
.setting-description {
font-size: 12px;
color: var(--text-light-03);
}
.setting-divider {
height: 1px;
background: var(--white-10);
margin: 0 4px;
}
.input-field {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--white-10);
border-radius: 8px;
font-size: 14px;
background: rgba(255, 255, 255, 0.05);
color: var(--text-light-05);
font-family: var(--font-hanken-grotesk);
transition: all 0.2s;
margin: 0;
}
.input-field:focus {
outline: none;
border-color: var(--white-30);
background: rgba(255, 255, 255, 0.1);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1);
color: var(--text-light-05);
}
.input-field::placeholder {
color: var(--text-light-03);
}
.setting-row .input-field {
margin-top: 0;
}
.toggle-switch {
position: relative;
display: inline-block;
width: 44px;
height: 24px;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(255, 255, 255, 0.2);
transition: 0.3s;
border-radius: 24px;
}
.toggle-slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: white;
transition: 0.3s;
border-radius: 50%;
}
input:checked + .toggle-slider {
background-color: rgba(255, 255, 255, 0.4);
}
input:checked + .toggle-slider:before {
transform: translateX(20px);
}
.status-container {
margin-top: 20px;
padding: 12px;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
opacity: 0;
transition: opacity 0.3s;
}
.status-container.show {
opacity: 1;
}
.status-message {
margin: 0 0 12px 0;
color: var(--text-light-05);
font-size: 14px;
line-height: 1.5;
}
.button {
padding: 10px 20px;
border-radius: 8px;
border: none;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.2s;
font-family: var(--font-hanken-grotesk);
}
.button.secondary {
background: var(--white-10);
color: var(--text-light-05);
width: 100%;
}
.button.secondary:hover {
background: var(--white-15);
}
kbd {
background: rgba(255, 255, 255, 0.1);
border: 1px solid var(--white-10);
border-radius: 4px;
padding: 2px 6px;
font-family: monospace;
font-weight: 500;
color: var(--text-light-05);
font-size: 11px;
}
@media (max-width: 600px) {
.settings-container {
padding: 20px 16px;
}
.settings-header {
padding: 20px;
}
.settings-content {
padding: 20px;
}
}
</style>
</head>
<body>
<div class="settings-container">
<div class="settings-panel">
<div class="settings-header">
<div class="settings-header-left">
<div class="settings-icon">
<img src="../../public/icon48.png" alt="Onyx" />
</div>
<h1 class="settings-title">Settings</h1>
</div>
<button
class="theme-toggle"
id="themeToggle"
aria-label="Toggle theme"
>
<svg
id="themeIcon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
>
<circle cx="12" cy="12" r="4"></circle>
<path
d="M12 2v2m0 16v2M4.93 4.93l1.41 1.41m11.32 11.32l1.41 1.41M2 12h2m16 0h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"
></path>
</svg>
</button>
</div>
<div class="settings-content">
<!-- General Section -->
<section class="settings-section">
<div class="section-title">General</div>
<div class="settings-group">
<div class="setting-row">
<div class="setting-row-content">
<label class="setting-label" for="onyxDomain"
>Root Domain</label
>
<div class="setting-description">
The root URL for your Onyx instance
</div>
</div>
</div>
<div class="setting-divider"></div>
<div class="setting-row" style="padding: 12px">
<input
type="text"
id="onyxDomain"
class="input-field"
placeholder="https://cloud.onyx.app"
/>
</div>
<div class="setting-divider"></div>
<div class="setting-row">
<div class="setting-row-content">
<label class="setting-label" for="useOnyxAsDefault"
>Use Onyx as new tab page</label
>
</div>
<label class="toggle-switch">
<input type="checkbox" id="useOnyxAsDefault" />
<span class="toggle-slider"></span>
</label>
</div>
</div>
</section>
<!-- Search Engine Section -->
<section class="settings-section">
<div class="section-title">Search Engine</div>
<div class="settings-group">
<div class="setting-row">
<div class="setting-row-content">
<label class="setting-label">Use Onyx in Address Bar</label>
<div class="setting-description">
Type <kbd>onyx</kbd> followed by a space in Chrome's address
bar, then enter your search query and press Enter
</div>
</div>
</div>
<div class="setting-divider"></div>
<div class="setting-row">
<div class="setting-row-content">
<div class="setting-description">
Searches will be directed to your configured Onyx instance
at the Root Domain above
</div>
</div>
</div>
</div>
</section>
<!-- Status Message -->
<div id="statusContainer" class="status-container">
<p id="status" class="status-message"></p>
<button id="newTab" class="button secondary" style="display: none">
Open New Tab to Test
</button>
</div>
</div>
</div>
</div>
<script type="module" src="options.js"></script>
</body>
</html>

View File

@@ -0,0 +1,142 @@
import {
CHROME_SPECIFIC_STORAGE_KEYS,
DEFAULT_ONYX_DOMAIN,
} from "../utils/constants.js";
document.addEventListener("DOMContentLoaded", function () {
const domainInput = document.getElementById("onyxDomain");
const useOnyxAsDefaultToggle = document.getElementById("useOnyxAsDefault");
const statusContainer = document.getElementById("statusContainer");
const statusElement = document.getElementById("status");
const newTabButton = document.getElementById("newTab");
const themeToggle = document.getElementById("themeToggle");
const themeIcon = document.getElementById("themeIcon");
let currentTheme = "dark";
function updateThemeIcon(theme) {
if (!themeIcon) return;
if (theme === "light") {
themeIcon.innerHTML = `
<circle cx="12" cy="12" r="4"></circle>
<path d="M12 2v2m0 16v2M4.93 4.93l1.41 1.41m11.32 11.32l1.41 1.41M2 12h2m16 0h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"></path>
`;
} else {
themeIcon.innerHTML = `
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
`;
}
}
function loadStoredValues() {
chrome.storage.local.get(
{
[CHROME_SPECIFIC_STORAGE_KEYS.ONYX_DOMAIN]: DEFAULT_ONYX_DOMAIN,
[CHROME_SPECIFIC_STORAGE_KEYS.USE_ONYX_AS_DEFAULT_NEW_TAB]: false,
[CHROME_SPECIFIC_STORAGE_KEYS.THEME]: "dark",
},
(result) => {
if (domainInput)
domainInput.value = result[CHROME_SPECIFIC_STORAGE_KEYS.ONYX_DOMAIN];
if (useOnyxAsDefaultToggle)
useOnyxAsDefaultToggle.checked =
result[CHROME_SPECIFIC_STORAGE_KEYS.USE_ONYX_AS_DEFAULT_NEW_TAB];
currentTheme = result[CHROME_SPECIFIC_STORAGE_KEYS.THEME] || "dark";
updateThemeIcon(currentTheme);
document.body.className = currentTheme === "light" ? "light-theme" : "";
},
);
}
function saveSettings() {
const domain = domainInput.value.trim();
const useOnyxAsDefault = useOnyxAsDefaultToggle
? useOnyxAsDefaultToggle.checked
: false;
chrome.storage.local.set(
{
[CHROME_SPECIFIC_STORAGE_KEYS.ONYX_DOMAIN]: domain,
[CHROME_SPECIFIC_STORAGE_KEYS.USE_ONYX_AS_DEFAULT_NEW_TAB]:
useOnyxAsDefault,
[CHROME_SPECIFIC_STORAGE_KEYS.THEME]: currentTheme,
},
() => {
showStatusMessage(
useOnyxAsDefault
? "Settings updated. Open a new tab to test it out. Click on the extension icon to bring up Onyx from any page."
: "Settings updated.",
);
},
);
}
function showStatusMessage(message) {
if (statusElement) {
const useOnyxAsDefault = useOnyxAsDefaultToggle
? useOnyxAsDefaultToggle.checked
: false;
statusElement.textContent =
message ||
(useOnyxAsDefault
? "Settings updated. Open a new tab to test it out. Click on the extension icon to bring up Onyx from any page."
: "Settings updated.");
if (newTabButton) {
newTabButton.style.display = useOnyxAsDefault ? "block" : "none";
}
}
if (statusContainer) {
statusContainer.classList.add("show");
}
setTimeout(hideStatusMessage, 5000);
}
function hideStatusMessage() {
if (statusContainer) {
statusContainer.classList.remove("show");
}
}
function toggleTheme() {
currentTheme = currentTheme === "light" ? "dark" : "light";
updateThemeIcon(currentTheme);
document.body.className = currentTheme === "light" ? "light-theme" : "";
chrome.storage.local.set({
[CHROME_SPECIFIC_STORAGE_KEYS.THEME]: currentTheme,
});
}
function openNewTab() {
chrome.tabs.create({});
}
if (domainInput) {
domainInput.addEventListener("input", () => {
clearTimeout(domainInput.saveTimeout);
domainInput.saveTimeout = setTimeout(saveSettings, 1000);
});
}
if (useOnyxAsDefaultToggle) {
useOnyxAsDefaultToggle.addEventListener("change", saveSettings);
}
if (themeToggle) {
themeToggle.addEventListener("click", toggleTheme);
}
if (newTabButton) {
newTabButton.addEventListener("click", openNewTab);
}
loadStoredValues();
});

View File

@@ -0,0 +1,91 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Permissions-Policy" content="clipboard-write=(self)" />
<title>Onyx Panel</title>
<link rel="stylesheet" href="../styles/shared.css" />
<style>
body,
html {
margin: 0;
padding: 0;
width: 100%;
height: 100vh;
overflow: hidden;
}
#loading-screen {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: #f5f5f5;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
z-index: 1000;
transition: opacity 0.5s ease-in-out;
}
#logo {
width: 100px;
height: 100px;
background-image: url("/public/logo.png");
background-size: contain;
background-repeat: no-repeat;
background-position: center;
animation: pulse 2s infinite;
}
@keyframes pulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
}
}
#loading-text {
color: #0a0a0a;
margin-top: 20px;
font-size: 1.125rem;
font-weight: 600;
text-align: center;
}
iframe {
border: none;
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
opacity: 0;
transition: opacity 0.5s ease-in-out;
}
</style>
</head>
<body>
<div id="loading-screen">
<div id="logo"></div>
<div id="loading-text">Loading Onyx...</div>
</div>
<iframe
id="onyx-panel-iframe"
allow="clipboard-read; clipboard-write"
></iframe>
<script src="../utils/error-modal.js" type="module"></script>
<script src="panel.js" type="module"></script>
</body>
</html>

View File

@@ -0,0 +1,127 @@
import { showErrorModal, showAuthModal } from "../utils/error-modal.js";
import {
ACTIONS,
CHROME_MESSAGE,
WEB_MESSAGE,
CHROME_SPECIFIC_STORAGE_KEYS,
SIDE_PANEL_PATH,
} from "../utils/constants.js";
(function () {
const iframe = document.getElementById("onyx-panel-iframe");
const loadingScreen = document.getElementById("loading-screen");
let currentUrl = "";
let iframeLoaded = false;
let iframeLoadTimeout;
let authRequired = false;
async function checkPendingInput() {
try {
const result = await chrome.storage.session.get("pendingInput");
if (result.pendingInput) {
const { url, pageUrl, timestamp } = result.pendingInput;
if (Date.now() - timestamp < 5000) {
setIframeSrc(url, pageUrl);
await chrome.storage.session.remove("pendingInput");
return true;
}
await chrome.storage.session.remove("pendingInput");
}
} catch (error) {
console.error("[Onyx Panel] Error checking pending input:", error);
}
return false;
}
async function initializePanel() {
loadingScreen.style.display = "flex";
loadingScreen.style.opacity = "1";
iframe.style.opacity = "0";
// Check for pending input first (from selection icon click)
const hasPendingInput = await checkPendingInput();
if (!hasPendingInput) {
loadOnyxDomain();
}
}
function setIframeSrc(url, pageUrl) {
iframe.src = url;
currentUrl = pageUrl;
}
function sendWebsiteToIframe(pageUrl) {
if (iframe.contentWindow && pageUrl !== currentUrl) {
iframe.contentWindow.postMessage(
{
type: WEB_MESSAGE.PAGE_CHANGE,
url: pageUrl,
},
"*",
);
currentUrl = pageUrl;
}
}
function startIframeLoadTimeout() {
iframeLoadTimeout = setTimeout(() => {
if (!iframeLoaded) {
if (authRequired) {
showAuthModal();
} else {
showErrorModal(iframe.src);
}
}
}, 2500);
}
function handleMessage(event) {
if (event.data.type === CHROME_MESSAGE.ONYX_APP_LOADED) {
clearTimeout(iframeLoadTimeout);
iframeLoaded = true;
showIframe();
if (iframe.contentWindow) {
iframe.contentWindow.postMessage({ type: "PANEL_READY" }, "*");
}
} else if (event.data.type === CHROME_MESSAGE.AUTH_REQUIRED) {
authRequired = true;
}
}
function showIframe() {
iframe.style.opacity = "1";
loadingScreen.style.opacity = "0";
setTimeout(() => {
loadingScreen.style.display = "none";
}, 500);
}
async function loadOnyxDomain() {
const response = await chrome.runtime.sendMessage({
action: ACTIONS.GET_CURRENT_ONYX_DOMAIN,
});
if (response && response[CHROME_SPECIFIC_STORAGE_KEYS.ONYX_DOMAIN]) {
setIframeSrc(
response[CHROME_SPECIFIC_STORAGE_KEYS.ONYX_DOMAIN] + SIDE_PANEL_PATH,
"",
);
} else {
console.warn("Onyx domain not found, using default");
const domain = await getOnyxDomain();
setIframeSrc(domain + SIDE_PANEL_PATH, "");
}
}
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
if (request.action === ACTIONS.OPEN_ONYX_WITH_INPUT) {
setIframeSrc(request.url, request.pageUrl);
} else if (request.action === ACTIONS.UPDATE_PAGE_URL) {
sendWebsiteToIframe(request.pageUrl);
}
});
window.addEventListener("message", handleMessage);
initializePanel();
startIframeLoadTimeout();
})();

View File

@@ -0,0 +1,252 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Permissions-Policy" content="clipboard-write=(self)" />
<title>Onyx</title>
<link rel="stylesheet" href="../styles/shared.css" />
<style>
:root {
--background-900: #0a0a0a;
--background-800: #1a1a1a;
--text-light-05: rgba(255, 255, 255, 0.95);
--text-light-03: rgba(255, 255, 255, 0.6);
--white-10: rgba(255, 255, 255, 0.1);
--white-15: rgba(255, 255, 255, 0.15);
--white-20: rgba(255, 255, 255, 0.2);
}
* {
box-sizing: border-box;
}
body {
width: 300px;
margin: 0;
padding: 0;
font-family: var(--font-hanken-grotesk);
background: linear-gradient(
135deg,
var(--background-900) 0%,
var(--background-800) 100%
);
color: var(--text-light-05);
}
.popup-container {
padding: 16px;
}
.popup-header {
display: flex;
align-items: center;
gap: 12px;
padding-bottom: 16px;
border-bottom: 1px solid var(--white-10);
margin-bottom: 16px;
}
.popup-icon {
width: 36px;
height: 36px;
border-radius: 10px;
background: white;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.popup-icon img {
width: 100%;
height: 100%;
object-fit: contain;
padding: 4px;
}
.popup-title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--text-light-05);
}
.menu-button-content {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.menu-button-text {
display: flex;
align-items: center;
gap: 10px;
}
.menu-button-shortcut {
font-size: 11px;
color: var(--text-light-03);
font-weight: 400;
margin-left: auto;
}
.settings-group {
background: rgba(255, 255, 255, 0.05);
border-radius: 12px;
padding: 4px;
margin-bottom: 12px;
}
.setting-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
}
.setting-label {
font-size: 14px;
font-weight: 400;
color: var(--text-light-05);
}
.setting-divider {
height: 1px;
background: var(--white-10);
margin: 0 4px;
}
.menu-button {
background: rgba(255, 255, 255, 0.05);
border: none;
padding: 12px;
width: 100%;
text-align: left;
cursor: pointer;
font-size: 14px;
color: var(--text-light-05);
font-weight: 400;
transition: background 0.2s;
border-radius: 12px;
font-family: var(--font-hanken-grotesk);
}
.menu-button:hover {
background: rgba(255, 255, 255, 0.1);
}
.menu-button svg {
width: 18px;
height: 18px;
stroke: var(--text-light-05);
fill: none;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.button-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.toggle-switch {
position: relative;
display: inline-block;
width: 44px;
height: 24px;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(255, 255, 255, 0.2);
transition: 0.3s;
border-radius: 24px;
}
.toggle-slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: white;
transition: 0.3s;
border-radius: 50%;
}
input:checked + .toggle-slider {
background-color: rgba(255, 255, 255, 0.4);
}
input:checked + .toggle-slider:before {
transform: translateX(20px);
}
</style>
</head>
<body>
<div class="popup-container">
<div class="popup-header">
<div class="popup-icon">
<img src="../../public/icon48.png" alt="Onyx" />
</div>
<h2 class="popup-title">Onyx</h2>
</div>
<div class="settings-group">
<div class="setting-row">
<label class="setting-label" for="defaultNewTabToggle">
Use Onyx as new tab page
</label>
<label class="toggle-switch">
<input type="checkbox" id="defaultNewTabToggle" />
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="button-group">
<button class="menu-button" id="openSidePanel">
<div class="menu-button-content">
<div class="menu-button-text">
<svg viewBox="0 0 24 24">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<line x1="15" y1="3" x2="15" y2="21"></line>
</svg>
Open Onyx Panel
</div>
<span class="menu-button-shortcut">Ctrl+O</span>
</div>
</button>
<button class="menu-button" id="openOptions">
<div class="menu-button-text">
<svg viewBox="0 0 24 24">
<circle cx="12" cy="12" r="3"></circle>
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"
></path>
</svg>
Extension Settings
</div>
</button>
</div>
</div>
<script type="module" src="popup.js"></script>
</body>
</html>

View File

@@ -0,0 +1,58 @@
import { CHROME_SPECIFIC_STORAGE_KEYS } from "../utils/constants.js";
document.addEventListener("DOMContentLoaded", async function () {
const defaultNewTabToggle = document.getElementById("defaultNewTabToggle");
const openSidePanelButton = document.getElementById("openSidePanel");
const openOptionsButton = document.getElementById("openOptions");
async function loadSetting() {
const result = await chrome.storage.local.get({
[CHROME_SPECIFIC_STORAGE_KEYS.USE_ONYX_AS_DEFAULT_NEW_TAB]: false,
});
if (defaultNewTabToggle) {
defaultNewTabToggle.checked =
result[CHROME_SPECIFIC_STORAGE_KEYS.USE_ONYX_AS_DEFAULT_NEW_TAB];
}
}
async function toggleSetting() {
const currentValue = defaultNewTabToggle.checked;
await chrome.storage.local.set({
[CHROME_SPECIFIC_STORAGE_KEYS.USE_ONYX_AS_DEFAULT_NEW_TAB]: currentValue,
});
}
async function openSidePanel() {
try {
const [tab] = await chrome.tabs.query({
active: true,
currentWindow: true,
});
if (tab && chrome.sidePanel) {
await chrome.sidePanel.open({ tabId: tab.id });
window.close();
}
} catch (error) {
console.error("Error opening side panel:", error);
}
}
function openOptions() {
chrome.runtime.openOptionsPage();
window.close();
}
await loadSetting();
if (defaultNewTabToggle) {
defaultNewTabToggle.addEventListener("change", toggleSetting);
}
if (openSidePanelButton) {
openSidePanelButton.addEventListener("click", openSidePanel);
}
if (openOptionsButton) {
openOptionsButton.addEventListener("click", openOptions);
}
});

View File

@@ -0,0 +1,618 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Welcome to Onyx</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@300;400;500;600;700&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="../styles/shared.css" />
<style>
:root {
--background-900: #0a0a0a;
--background-800: #1a1a1a;
--text-light-05: rgba(255, 255, 255, 0.95);
--text-light-03: rgba(255, 255, 255, 0.6);
--white-10: rgba(255, 255, 255, 0.1);
--white-15: rgba(255, 255, 255, 0.15);
--white-20: rgba(255, 255, 255, 0.2);
--white-30: rgba(255, 255, 255, 0.3);
--white-40: rgba(255, 255, 255, 0.4);
--white-80: rgba(255, 255, 255, 0.8);
--black-40: rgba(0, 0, 0, 0.4);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 0;
font-family: var(--font-hanken-grotesk);
background: linear-gradient(
135deg,
var(--background-900) 0%,
var(--background-800) 100%
);
min-height: 100vh;
color: var(--text-light-05);
transition: background 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
}
body.light-theme {
--background-900: #f5f5f5;
--background-800: #ffffff;
--text-light-05: rgba(0, 0, 0, 0.95);
--text-light-03: rgba(0, 0, 0, 0.6);
background: linear-gradient(135deg, #f5f5f5 0%, #ffffff 100%);
}
body.light-theme .welcome-panel {
background: linear-gradient(
to bottom,
rgba(255, 255, 255, 0.95),
rgba(245, 245, 245, 0.95)
);
border: 1px solid rgba(0, 0, 0, 0.1);
}
body.light-theme .welcome-header {
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
}
body.light-theme .logo-container {
background: rgba(0, 0, 0, 0.05);
}
body.light-theme .theme-toggle {
background: rgba(0, 0, 0, 0.05);
border: 1px solid rgba(0, 0, 0, 0.1);
}
body.light-theme .theme-toggle:hover {
background: rgba(0, 0, 0, 0.08);
}
body.light-theme .theme-toggle svg {
stroke: rgba(0, 0, 0, 0.95);
}
body.light-theme .input-field {
border: 1px solid rgba(0, 0, 0, 0.1);
background: rgba(0, 0, 0, 0.05);
color: rgba(0, 0, 0, 0.95);
}
body.light-theme .input-field:focus {
outline: none;
border-color: rgba(0, 0, 0, 0.25);
background: rgba(0, 0, 0, 0.08);
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.05);
}
body.light-theme .input-field::placeholder {
color: rgba(0, 0, 0, 0.4);
}
body.light-theme .toggle-slider {
background-color: rgba(0, 0, 0, 0.15);
}
body.light-theme input:checked + .toggle-slider {
background-color: rgba(0, 0, 0, 0.3);
}
body.light-theme .toggle-slider:before {
background-color: white;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
body.light-theme .step-dot {
background: rgba(0, 0, 0, 0.2);
}
body.light-theme .step-dot.active {
background: rgba(0, 0, 0, 0.6);
}
body.light-theme .btn-primary {
background: rgba(0, 0, 0, 0.9);
color: white;
}
body.light-theme .btn-primary:hover {
background: rgba(0, 0, 0, 0.8);
}
body.light-theme .btn-secondary {
background: rgba(0, 0, 0, 0.05);
color: rgba(0, 0, 0, 0.95);
}
body.light-theme .btn-secondary:hover {
background: rgba(0, 0, 0, 0.08);
}
body.light-theme .settings-group {
background: rgba(0, 0, 0, 0.03);
}
body.light-theme .setting-divider {
background: rgba(0, 0, 0, 0.1);
}
.welcome-container {
max-width: 480px;
width: 100%;
margin: 0 auto;
padding: 40px 20px;
}
.welcome-panel {
background: linear-gradient(
to bottom,
rgba(10, 10, 10, 0.95),
rgba(26, 26, 26, 0.95)
);
backdrop-filter: blur(24px);
border-radius: 20px;
border: 1px solid var(--white-10);
overflow: hidden;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
animation: panelFadeIn 0.5s ease-out;
}
@keyframes panelFadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.welcome-header {
padding: 24px;
border-bottom: 1px solid var(--white-10);
display: flex;
align-items: center;
justify-content: space-between;
}
.header-left {
display: flex;
align-items: center;
gap: 14px;
}
.logo-container {
width: 48px;
height: 48px;
border-radius: 14px;
background: white;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.logo-container img {
width: 100%;
height: 100%;
object-fit: contain;
padding: 8px;
}
.welcome-title {
font-size: 22px;
font-weight: 600;
color: var(--text-light-05);
margin: 0;
}
.theme-toggle {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 999px;
background: var(--white-10);
border: 1px solid var(--white-10);
cursor: pointer;
transition: all 0.2s;
}
.theme-toggle:hover {
background: var(--white-15);
}
.theme-toggle svg {
width: 18px;
height: 18px;
stroke: var(--text-light-05);
}
.welcome-content {
padding: 32px 24px;
}
/* Step indicator */
.step-indicator {
display: flex;
justify-content: center;
gap: 8px;
margin-bottom: 32px;
}
.step-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--white-20);
transition: all 0.3s ease;
}
.step-dot.active {
background: var(--white-80);
transform: scale(1.2);
}
/* Steps */
.step {
display: none;
animation: stepFadeIn 0.4s ease-out;
}
.step.active {
display: block;
}
@keyframes stepFadeIn {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.step-title {
font-size: 24px;
font-weight: 600;
margin: 0 0 8px 0;
text-align: center;
}
.step-description {
font-size: 15px;
color: var(--text-light-03);
text-align: center;
margin: 0 0 28px 0;
line-height: 1.5;
}
/* Form elements */
.input-group {
margin-bottom: 24px;
}
.input-label {
display: block;
font-size: 13px;
font-weight: 500;
color: var(--text-light-03);
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.input-field {
width: 100%;
padding: 14px 16px;
border: 1px solid var(--white-10);
border-radius: 12px;
font-size: 15px;
background: rgba(255, 255, 255, 0.95);
color: rgba(0, 0, 0, 0.9);
font-family: var(--font-hanken-grotesk);
transition: all 0.2s;
}
.input-field:focus {
outline: none;
border-color: var(--white-30);
background: rgba(255, 255, 255, 1);
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.15);
}
.input-field::placeholder {
color: rgba(0, 0, 0, 0.4);
}
/* Settings group for step 2 */
.settings-group {
background: rgba(255, 255, 255, 0.05);
border-radius: 16px;
padding: 4px;
margin-bottom: 24px;
}
.setting-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
}
.setting-content {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
padding-right: 16px;
}
.setting-label {
font-size: 15px;
font-weight: 500;
color: var(--text-light-05);
}
.setting-description {
font-size: 13px;
color: var(--text-light-03);
line-height: 1.4;
}
.setting-divider {
height: 1px;
background: var(--white-10);
margin: 0 8px;
}
/* Toggle switch */
.toggle-switch {
position: relative;
display: inline-block;
width: 52px;
height: 28px;
flex-shrink: 0;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(255, 255, 255, 0.2);
transition: 0.3s;
border-radius: 28px;
}
.toggle-slider:before {
position: absolute;
content: "";
height: 22px;
width: 22px;
left: 3px;
bottom: 3px;
background-color: white;
transition: 0.3s;
border-radius: 50%;
}
input:checked + .toggle-slider {
background-color: rgba(255, 255, 255, 0.4);
}
input:checked + .toggle-slider:before {
transform: translateX(24px);
}
/* Buttons */
.button-group {
display: flex;
gap: 12px;
margin-top: 8px;
}
.btn {
flex: 1;
padding: 14px 24px;
border-radius: 12px;
border: none;
cursor: pointer;
font-size: 15px;
font-weight: 500;
font-family: var(--font-hanken-grotesk);
transition: all 0.2s;
}
.btn-primary {
background: rgba(255, 255, 255, 0.95);
color: #0a0a0a;
}
.btn-primary:hover {
background: rgba(255, 255, 255, 0.85);
transform: translateY(-1px);
}
.btn-secondary {
background: var(--white-10);
color: var(--text-light-05);
}
.btn-secondary:hover {
background: var(--white-15);
}
.btn:active {
transform: translateY(0);
}
/* Success animation for completion */
.success-icon {
width: 64px;
height: 64px;
margin: 0 auto 24px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
display: flex;
align-items: center;
justify-content: center;
animation: successPop 0.5s ease-out;
}
.success-icon svg {
width: 32px;
height: 32px;
stroke: var(--text-light-05);
stroke-width: 2.5;
}
@keyframes successPop {
0% {
transform: scale(0);
opacity: 0;
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
opacity: 1;
}
}
@media (max-width: 500px) {
.welcome-container {
padding: 20px 16px;
}
.welcome-content {
padding: 24px 20px;
}
.step-title {
font-size: 20px;
}
.button-group {
flex-direction: column;
}
}
</style>
</head>
<body>
<div class="welcome-container">
<div class="welcome-panel">
<div class="welcome-header">
<div class="header-left">
<div class="logo-container">
<img src="../../public/icon48.png" alt="Onyx" />
</div>
<h1 class="welcome-title">Onyx</h1>
</div>
<button
class="theme-toggle"
id="themeToggle"
aria-label="Toggle theme"
>
<svg
id="themeIcon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
>
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
</svg>
</button>
</div>
<div class="welcome-content">
<div class="step-indicator">
<div class="step-dot active" data-step="1"></div>
<div class="step-dot" data-step="2"></div>
</div>
<!-- Step 1: Root Domain -->
<div class="step active" id="step1">
<h2 class="step-title">Welcome to Onyx</h2>
<p class="step-description">
Enter your Onyx instance URL to get started. This is where your
Onyx deployment is hosted.
</p>
<div class="input-group">
<label class="input-label" for="onyxDomain">Root Domain</label>
<input
type="text"
id="onyxDomain"
class="input-field"
placeholder="https://cloud.onyx.app"
/>
</div>
<div class="button-group">
<button class="btn btn-primary" id="continueBtn">Continue</button>
</div>
</div>
<!-- Step 2: New Tab Setting -->
<div class="step" id="step2">
<h2 class="step-title">Customize Your Experience</h2>
<p class="step-description">
Set Onyx as your new tab page for quick access to your AI
assistant.
</p>
<div class="settings-group">
<div class="setting-row">
<div class="setting-content">
<span class="setting-label">Use Onyx as new tab page</span>
<span class="setting-description"
>Open Onyx every time you create a new tab</span
>
</div>
<label class="toggle-switch">
<input type="checkbox" id="useOnyxAsDefault" checked />
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="button-group">
<button class="btn btn-secondary" id="backBtn">Back</button>
<button class="btn btn-primary" id="finishBtn">
Get Started
</button>
</div>
</div>
</div>
</div>
</div>
<script type="module" src="welcome.js"></script>
</body>
</html>

View File

@@ -0,0 +1,192 @@
import {
CHROME_SPECIFIC_STORAGE_KEYS,
DEFAULT_ONYX_DOMAIN,
} from "../utils/constants.js";
document.addEventListener("DOMContentLoaded", function () {
const domainInput = document.getElementById("onyxDomain");
const useOnyxAsDefaultToggle = document.getElementById("useOnyxAsDefault");
const continueBtn = document.getElementById("continueBtn");
const backBtn = document.getElementById("backBtn");
const finishBtn = document.getElementById("finishBtn");
const themeToggle = document.getElementById("themeToggle");
const themeIcon = document.getElementById("themeIcon");
const step1 = document.getElementById("step1");
const step2 = document.getElementById("step2");
const stepDots = document.querySelectorAll(".step-dot");
let currentStep = 1;
let currentTheme = "dark";
// Initialize theme based on system preference or stored value
function initTheme() {
chrome.storage.local.get(
{ [CHROME_SPECIFIC_STORAGE_KEYS.THEME]: null },
(result) => {
const storedTheme = result[CHROME_SPECIFIC_STORAGE_KEYS.THEME];
if (storedTheme) {
currentTheme = storedTheme;
} else {
// Check system preference
currentTheme = window.matchMedia("(prefers-color-scheme: light)")
.matches
? "light"
: "dark";
}
applyTheme();
},
);
}
function applyTheme() {
document.body.className = currentTheme === "light" ? "light-theme" : "";
updateThemeIcon();
}
function updateThemeIcon() {
if (!themeIcon) return;
if (currentTheme === "light") {
themeIcon.innerHTML = `
<circle cx="12" cy="12" r="4"></circle>
<path d="M12 2v2m0 16v2M4.93 4.93l1.41 1.41m11.32 11.32l1.41 1.41M2 12h2m16 0h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"></path>
`;
} else {
themeIcon.innerHTML = `
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
`;
}
}
function toggleTheme() {
currentTheme = currentTheme === "light" ? "dark" : "light";
applyTheme();
chrome.storage.local.set({
[CHROME_SPECIFIC_STORAGE_KEYS.THEME]: currentTheme,
});
}
function goToStep(step) {
if (step === 1) {
step2.classList.remove("active");
setTimeout(() => {
step1.classList.add("active");
}, 50);
} else if (step === 2) {
step1.classList.remove("active");
setTimeout(() => {
step2.classList.add("active");
}, 50);
}
stepDots.forEach((dot) => {
const dotStep = parseInt(dot.dataset.step);
if (dotStep === step) {
dot.classList.add("active");
} else {
dot.classList.remove("active");
}
});
currentStep = step;
}
// Validate domain input
function validateDomain(domain) {
if (!domain) return false;
try {
new URL(domain);
return true;
} catch {
return false;
}
}
function handleContinue() {
const domain = domainInput.value.trim();
if (domain && !validateDomain(domain)) {
domainInput.style.borderColor = "rgba(255, 100, 100, 0.5)";
domainInput.focus();
return;
}
domainInput.style.borderColor = "";
goToStep(2);
}
function handleBack() {
goToStep(1);
}
function handleFinish() {
const domain = domainInput.value.trim() || DEFAULT_ONYX_DOMAIN;
const useOnyxAsDefault = useOnyxAsDefaultToggle.checked;
chrome.storage.local.set(
{
[CHROME_SPECIFIC_STORAGE_KEYS.ONYX_DOMAIN]: domain,
[CHROME_SPECIFIC_STORAGE_KEYS.USE_ONYX_AS_DEFAULT_NEW_TAB]:
useOnyxAsDefault,
[CHROME_SPECIFIC_STORAGE_KEYS.THEME]: currentTheme,
[CHROME_SPECIFIC_STORAGE_KEYS.ONBOARDING_COMPLETE]: true,
},
() => {
// Open a new tab if they enabled the new tab feature, otherwise just close
if (useOnyxAsDefault) {
chrome.tabs.create({}, () => {
window.close();
});
} else {
window.close();
}
},
);
}
// Load any existing values (in case user returns to this page)
function loadStoredValues() {
chrome.storage.local.get(
{
[CHROME_SPECIFIC_STORAGE_KEYS.ONYX_DOMAIN]: "",
[CHROME_SPECIFIC_STORAGE_KEYS.USE_ONYX_AS_DEFAULT_NEW_TAB]: true,
},
(result) => {
if (result[CHROME_SPECIFIC_STORAGE_KEYS.ONYX_DOMAIN]) {
domainInput.value = result[CHROME_SPECIFIC_STORAGE_KEYS.ONYX_DOMAIN];
}
useOnyxAsDefaultToggle.checked =
result[CHROME_SPECIFIC_STORAGE_KEYS.USE_ONYX_AS_DEFAULT_NEW_TAB];
},
);
}
if (themeToggle) {
themeToggle.addEventListener("click", toggleTheme);
}
if (continueBtn) {
continueBtn.addEventListener("click", handleContinue);
}
if (backBtn) {
backBtn.addEventListener("click", handleBack);
}
if (finishBtn) {
finishBtn.addEventListener("click", handleFinish);
}
// Allow Enter key to proceed
if (domainInput) {
domainInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
handleContinue();
}
});
}
initTheme();
loadStoredValues();
});

View File

@@ -0,0 +1,42 @@
#onyx-selection-icon {
position: fixed;
z-index: 2147483647;
width: 32px;
height: 32px;
border-radius: 50%;
background-color: #ffffff;
border: 1px solid #e0e0e0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transform: scale(0.8);
transition:
opacity 0.15s ease,
transform 0.15s ease,
box-shadow 0.15s ease;
pointer-events: none;
}
#onyx-selection-icon.visible {
opacity: 1;
transform: scale(1);
pointer-events: auto;
}
#onyx-selection-icon:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
transform: scale(1.1);
}
#onyx-selection-icon:active {
transform: scale(0.95);
}
#onyx-selection-icon img {
width: 20px;
height: 20px;
pointer-events: none;
}

View File

@@ -0,0 +1,169 @@
/* Import Hanken Grotesk font */
@import url("https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@300;400;500;600;700&display=swap");
:root {
--primary-color: #4285f4;
--primary-hover-color: #3367d6;
--secondary-color: #f1f3f4;
--secondary-hover-color: #e8eaed;
--text-color: #333;
--text-light-color: #666;
--background-color: #f1f3f4;
--card-background-color: #fff;
--border-color: #ccc;
--font-family: Arial, sans-serif;
--font-hanken-grotesk: "Hanken Grotesk", sans-serif;
}
body {
font-family: var(--font-hanken-grotesk);
margin: 0;
padding: 0;
}
.container {
max-width: 500px;
width: 90%;
margin: 0 auto;
}
.card {
background-color: var(--card-background-color);
padding: 25px;
border-radius: 10px;
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.1);
}
h1 {
color: var(--text-color);
font-size: 24px;
font-weight: 600;
margin-top: 0;
margin-bottom: 20px;
}
.option-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 5px;
color: var(--text-light-color);
font-weight: 400;
font-size: 16px;
}
input[type="text"] {
width: 100%;
padding: 8px;
border: 1px solid var(--border-color);
border-radius: 4px;
font-size: 14px;
background-color: var(--card-background-color);
color: var(--text-color);
}
.button {
width: 100%;
padding: 10px 20px;
border-radius: 5px;
border: none;
cursor: pointer;
font-size: 16px;
font-weight: 500;
transition: background-color 0.3s;
}
.button.primary {
background-color: var(--primary-color);
color: #fff;
}
.button.primary:hover {
background-color: var(--primary-hover-color);
}
.button.secondary {
background-color: var(--secondary-color);
color: var(--text-color);
}
.button.secondary:hover {
background-color: var(--secondary-hover-color);
}
.status-container {
margin-top: 10px;
margin-bottom: 15px;
}
.status-message {
margin: 0 0 10px 0;
color: var(--text-color);
font-weight: 500;
text-align: center;
font-size: 16px;
transition: opacity 0.5s ease-in-out;
}
kbd {
background-color: var(--secondary-color);
border: 1px solid var(--border-color);
border-radius: 3px;
padding: 2px 5px;
font-family: monospace;
font-weight: 500;
color: var(--text-color);
}
.toggle-label {
display: flex;
justify-content: space-between;
align-items: center;
}
.toggle-switch {
position: relative;
display: inline-block;
width: 50px;
height: 24px;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--secondary-color);
transition: 0.4s;
border-radius: 24px;
}
.slider:before {
position: absolute;
content: "";
height: 20px;
width: 20px;
left: 2px;
bottom: 2px;
background-color: white;
transition: 0.4s;
border-radius: 50%;
}
input:checked + .slider {
background-color: var(--primary-color);
}
input:checked + .slider:before {
transform: translateX(26px);
}

View File

@@ -0,0 +1,43 @@
export const THEMES = {
LIGHT: "light",
DARK: "dark",
};
export const DEFAULT_ONYX_DOMAIN = "http://localhost:3000";
export const SIDE_PANEL_PATH = "/chat/nrf/side-panel";
export const ACTIONS = {
GET_SELECTED_TEXT: "getSelectedText",
GET_CURRENT_ONYX_DOMAIN: "getCurrentOnyxDomain",
UPDATE_PAGE_URL: "updatePageUrl",
SEND_TO_ONYX: "sendToOnyx",
OPEN_SIDE_PANEL: "openSidePanel",
TOGGLE_NEW_TAB_OVERRIDE: "toggleNewTabOverride",
OPEN_SIDE_PANEL_WITH_INPUT: "openSidePanelWithInput",
OPEN_ONYX_WITH_INPUT: "openOnyxWithInput",
CLOSE_SIDE_PANEL: "closeSidePanel",
};
export const CHROME_SPECIFIC_STORAGE_KEYS = {
ONYX_DOMAIN: "onyxExtensionDomain",
USE_ONYX_AS_DEFAULT_NEW_TAB: "onyxExtensionDefaultNewTab",
THEME: "onyxExtensionTheme",
BACKGROUND_IMAGE: "onyxExtensionBackgroundImage",
DARK_BG_URL: "onyxExtensionDarkBgUrl",
LIGHT_BG_URL: "onyxExtensionLightBgUrl",
ONBOARDING_COMPLETE: "onyxExtensionOnboardingComplete",
};
export const CHROME_MESSAGE = {
PREFERENCES_UPDATED: "PREFERENCES_UPDATED",
ONYX_APP_LOADED: "ONYX_APP_LOADED",
SET_DEFAULT_NEW_TAB: "SET_DEFAULT_NEW_TAB",
LOAD_NEW_CHAT_PAGE: "LOAD_NEW_CHAT_PAGE",
LOAD_NEW_PAGE: "LOAD_NEW_PAGE",
AUTH_REQUIRED: "AUTH_REQUIRED",
};
export const WEB_MESSAGE = {
PAGE_CHANGE: "PAGE_CHANGE",
};

View File

@@ -0,0 +1,34 @@
let sidePanel = null;
function createSidePanel() {
sidePanel = document.createElement("div");
sidePanel.id = "onyx-side-panel";
sidePanel.style.cssText = `
position: fixed;
top: 0;
right: -400px;
width: 400px;
height: 100%;
background-color: white;
box-shadow: -2px 0 5px rgba(0,0,0,0.2);
transition: right 0.3s ease-in-out;
z-index: 9999;
`;
const iframe = document.createElement("iframe");
iframe.style.cssText = `
width: 100%;
height: 100%;
border: none;
`;
chrome.runtime.sendMessage(
{ action: ACTIONS.GET_CURRENT_ONYX_DOMAIN },
function (response) {
iframe.src = response[CHROME_SPECIFIC_STORAGE_KEYS.ONYX_DOMAIN];
},
);
sidePanel.appendChild(iframe);
document.body.appendChild(sidePanel);
}

View File

@@ -0,0 +1,379 @@
import {
CHROME_SPECIFIC_STORAGE_KEYS,
DEFAULT_ONYX_DOMAIN,
ACTIONS,
} from "./constants.js";
const errorModalHTML = `
<div id="error-modal">
<div class="modal-backdrop"></div>
<div class="modal-content">
<div class="modal-header">
<div class="modal-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<line x1="12" y1="8" x2="12" y2="12"></line>
<line x1="12" y1="16" x2="12.01" y2="16"></line>
</svg>
</div>
<h2>Configuration Error</h2>
</div>
<div class="modal-body">
<p class="modal-description">The Onyx configuration needs to be updated. Please check your settings or contact your Onyx administrator.</p>
<div class="url-display">
<span class="url-label">Attempted to load:</span>
<span id="attempted-url" class="url-value"></span>
</div>
</div>
<div class="modal-footer">
<div class="button-container">
<button id="open-options" class="button primary">Open Extension Options</button>
<button id="disable-override" class="button secondary">Disable New Tab Override</button>
</div>
</div>
</div>
</div>
`;
const style = document.createElement("style");
style.textContent = `
:root {
--background-900: #0a0a0a;
--background-800: #1a1a1a;
--text-light-05: rgba(255, 255, 255, 0.95);
--text-light-03: rgba(255, 255, 255, 0.6);
--white-10: rgba(255, 255, 255, 0.1);
--white-15: rgba(255, 255, 255, 0.15);
--white-20: rgba(255, 255, 255, 0.2);
--white-30: rgba(255, 255, 255, 0.3);
}
#error-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: none;
align-items: center;
justify-content: center;
z-index: 2000;
font-family: var(--font-hanken-grotesk), 'Hanken Grotesk', sans-serif;
}
#error-modal .modal-backdrop {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(8px);
}
#error-modal .modal-content {
position: relative;
background: linear-gradient(to bottom, rgba(10, 10, 10, 0.95), rgba(26, 26, 26, 0.95));
backdrop-filter: blur(24px);
border-radius: 16px;
border: 1px solid var(--white-10);
max-width: 95%;
width: 500px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
overflow: hidden;
}
#error-modal .modal-header {
padding: 24px;
border-bottom: 1px solid var(--white-10);
display: flex;
align-items: center;
gap: 12px;
}
#error-modal .modal-icon {
width: 40px;
height: 40px;
border-radius: 12px;
background: rgba(255, 87, 87, 0.15);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
#error-modal .modal-icon svg {
width: 24px;
height: 24px;
stroke: #ff5757;
}
#error-modal .modal-icon.auth-icon {
background: rgba(66, 133, 244, 0.15);
}
#error-modal .modal-icon.auth-icon svg {
stroke: #4285f4;
}
#error-modal h2 {
margin: 0;
color: var(--text-light-05);
font-size: 20px;
font-weight: 600;
}
#error-modal .modal-body {
padding: 24px;
}
#error-modal .modal-description {
color: var(--text-light-05);
margin: 0 0 20px 0;
font-size: 14px;
line-height: 1.6;
font-weight: 400;
}
#error-modal .url-display {
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
padding: 12px;
border: 1px solid var(--white-10);
}
#error-modal .url-label {
display: block;
font-size: 12px;
color: var(--text-light-03);
margin-bottom: 6px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
}
#error-modal .url-value {
display: block;
font-size: 13px;
color: var(--text-light-05);
word-break: break-all;
font-family: monospace;
line-height: 1.5;
}
#error-modal .modal-footer {
padding: 0 24px 24px 24px;
}
#error-modal .button-container {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 16px;
}
#error-modal .button {
padding: 12px 20px;
border-radius: 8px;
border: none;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.2s;
font-family: var(--font-hanken-grotesk), 'Hanken Grotesk', sans-serif;
}
#error-modal .button.primary {
background: rgba(255, 255, 255, 0.15);
color: var(--text-light-05);
border: 1px solid var(--white-10);
}
#error-modal .button.primary:hover {
background: rgba(255, 255, 255, 0.2);
border-color: var(--white-20);
}
#error-modal .button.secondary {
background: rgba(255, 255, 255, 0.05);
color: var(--text-light-05);
border: 1px solid var(--white-10);
}
#error-modal .button.secondary:hover {
background: rgba(255, 255, 255, 0.1);
border-color: var(--white-15);
}
#error-modal kbd {
background: rgba(255, 255, 255, 0.1);
border: 1px solid var(--white-10);
border-radius: 4px;
padding: 2px 6px;
font-family: monospace;
font-weight: 500;
color: var(--text-light-05);
font-size: 11px;
}
@media (min-width: 768px) {
#error-modal .button-container {
flex-direction: row;
}
#error-modal .button {
flex: 1;
}
}
`;
const authModalHTML = `
<div id="error-modal">
<div class="modal-backdrop"></div>
<div class="modal-content">
<div class="modal-header">
<div class="modal-icon auth-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
</svg>
</div>
<h2>Authentication Required</h2>
</div>
<div class="modal-body">
<p class="modal-description">You need to log in to access Onyx. Click the button below to authenticate.</p>
</div>
<div class="modal-footer">
<div class="button-container">
<button id="open-auth" class="button primary">Log In to Onyx</button>
</div>
</div>
</div>
</div>
`;
let errorModal, attemptedUrlSpan, openOptionsButton, disableOverrideButton;
let authModal, openAuthButton;
export function initErrorModal() {
if (!document.getElementById("error-modal")) {
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = "../styles/shared.css";
document.head.appendChild(link);
document.body.insertAdjacentHTML("beforeend", errorModalHTML);
document.head.appendChild(style);
errorModal = document.getElementById("error-modal");
authModal = document.getElementById("error-modal");
attemptedUrlSpan = document.getElementById("attempted-url");
openOptionsButton = document.getElementById("open-options");
disableOverrideButton = document.getElementById("disable-override");
openOptionsButton.addEventListener("click", (e) => {
e.preventDefault();
chrome.runtime.openOptionsPage();
});
disableOverrideButton.addEventListener("click", () => {
chrome.storage.local.set({ useOnyxAsDefaultNewTab: false }, () => {
chrome.tabs.update({ url: "chrome://new-tab-page" });
});
});
}
}
export function showErrorModal(url) {
if (!errorModal) {
initErrorModal();
}
if (errorModal) {
errorModal.style.display = "flex";
errorModal.style.zIndex = "9999";
attemptedUrlSpan.textContent = url;
document.body.style.overflow = "hidden";
}
}
export function hideErrorModal() {
if (errorModal) {
errorModal.style.display = "none";
document.body.style.overflow = "auto";
}
}
export function checkModalVisibility() {
return errorModal
? window.getComputedStyle(errorModal).display !== "none"
: false;
}
export function initAuthModal() {
if (!document.getElementById("error-modal")) {
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = "../styles/shared.css";
document.head.appendChild(link);
document.body.insertAdjacentHTML("beforeend", authModalHTML);
document.head.appendChild(style);
authModal = document.getElementById("error-modal");
openAuthButton = document.getElementById("open-auth");
openAuthButton.addEventListener("click", (e) => {
e.preventDefault();
chrome.storage.local.get(
{ [CHROME_SPECIFIC_STORAGE_KEYS.ONYX_DOMAIN]: DEFAULT_ONYX_DOMAIN },
(result) => {
const onyxDomain = result[CHROME_SPECIFIC_STORAGE_KEYS.ONYX_DOMAIN];
chrome.runtime.sendMessage(
{ action: ACTIONS.CLOSE_SIDE_PANEL },
() => {
if (chrome.runtime.lastError) {
console.error(
"Error closing side panel:",
chrome.runtime.lastError,
);
}
chrome.tabs.create(
{
url: `${onyxDomain}/auth/login`,
active: true,
},
(_) => {
if (chrome.runtime.lastError) {
console.error(
"Error opening auth tab:",
chrome.runtime.lastError,
);
}
},
);
},
);
},
);
});
}
}
export function showAuthModal() {
if (!authModal) {
initAuthModal();
}
if (authModal) {
authModal.style.display = "flex";
authModal.style.zIndex = "9999";
document.body.style.overflow = "hidden";
}
}
export function hideAuthModal() {
if (authModal) {
authModal.style.display = "none";
document.body.style.overflow = "auto";
}
}

View File

@@ -0,0 +1,152 @@
(function () {
const OPEN_SIDE_PANEL_WITH_INPUT = "openSidePanelWithInput";
let selectionIcon = null;
let currentSelectedText = "";
function createSelectionIcon() {
if (selectionIcon) return;
selectionIcon = document.createElement("div");
selectionIcon.id = "onyx-selection-icon";
const img = document.createElement("img");
img.src = chrome.runtime.getURL("public/icon32.png");
img.alt = "Search with Onyx";
selectionIcon.appendChild(img);
document.body.appendChild(selectionIcon);
selectionIcon.addEventListener("mousedown", handleIconClick);
}
function showIcon(text) {
if (!selectionIcon) {
createSelectionIcon();
}
currentSelectedText = text;
const selection = window.getSelection();
if (!selection.rangeCount) return;
const range = selection.getRangeAt(0);
const rect = range.getBoundingClientRect();
const iconSize = 32;
const offset = 4;
let posX = rect.right + offset;
let posY = rect.bottom + offset;
if (posX + iconSize > window.innerWidth) {
posX = rect.left - iconSize - offset;
}
if (posY + iconSize > window.innerHeight) {
posY = rect.top - iconSize - offset;
}
posX = Math.max(
offset,
Math.min(posX, window.innerWidth - iconSize - offset),
);
posY = Math.max(
offset,
Math.min(posY, window.innerHeight - iconSize - offset),
);
selectionIcon.style.left = `${posX}px`;
selectionIcon.style.top = `${posY}px`;
selectionIcon.classList.add("visible");
}
function hideIcon() {
if (selectionIcon) {
selectionIcon.classList.remove("visible");
}
currentSelectedText = "";
}
function handleIconClick(e) {
e.preventDefault();
e.stopPropagation();
const textToSend = currentSelectedText;
if (textToSend) {
chrome.runtime.sendMessage(
{
action: OPEN_SIDE_PANEL_WITH_INPUT,
selectedText: textToSend,
pageUrl: window.location.href,
},
(response) => {
if (chrome.runtime.lastError) {
console.error(
"[Onyx] Error sending message:",
chrome.runtime.lastError.message,
);
} else {
}
},
);
}
hideIcon();
}
document.addEventListener("mouseup", (e) => {
if (
e.target.id === "onyx-selection-icon" ||
e.target.closest("#onyx-selection-icon")
) {
return;
}
setTimeout(() => {
const selection = window.getSelection();
const selectedText = selection.toString().trim();
if (selectedText && selectedText.length > 0) {
showIcon(selectedText);
} else {
hideIcon();
}
}, 10);
});
document.addEventListener("mousedown", (e) => {
if (
e.target.id !== "onyx-selection-icon" &&
!e.target.closest("#onyx-selection-icon")
) {
const selection = window.getSelection();
const selectedText = selection.toString().trim();
if (!selectedText) {
hideIcon();
}
}
});
document.addEventListener(
"scroll",
() => {
hideIcon();
},
true,
);
document.addEventListener("selectionchange", () => {
const selection = window.getSelection();
const selectedText = selection.toString().trim();
if (!selectedText) {
hideIcon();
}
});
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", createSelectionIcon);
} else {
createSelectionIcon();
}
})();

View File

@@ -0,0 +1,24 @@
import {
DEFAULT_ONYX_DOMAIN,
CHROME_SPECIFIC_STORAGE_KEYS,
} from "./constants.js";
export async function getOnyxDomain() {
const result = await chrome.storage.local.get({
[CHROME_SPECIFIC_STORAGE_KEYS.ONYX_DOMAIN]: DEFAULT_ONYX_DOMAIN,
});
return result[CHROME_SPECIFIC_STORAGE_KEYS.ONYX_DOMAIN];
}
export function setOnyxDomain(domain, callback) {
chrome.storage.local.set(
{ [CHROME_SPECIFIC_STORAGE_KEYS.ONYX_DOMAIN]: domain },
callback,
);
}
export function getOnyxDomainSync() {
return new Promise((resolve) => {
getOnyxDomain(resolve);
});
}