feat(max): expose max context from scene logics (#34173)

Co-authored-by: kappa90 <e.capparelli@gmail.com>
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Michael Matloka <michael@matloka.com>
Co-authored-by: Michael Matloka <dev@twixes.com>
This commit is contained in:
Emanuele Capparelli
2025-07-09 18:58:19 +01:00
committed by GitHub
parent 3599d5a119
commit 69bac3e4c0
38 changed files with 1044 additions and 911 deletions

52
cli/Cargo.lock generated
View File

@@ -196,9 +196,9 @@ dependencies = [
[[package]]
name = "bumpalo"
version = "3.19.0"
version = "3.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
checksum = "793db76d6187cd04dff33004d8e6c9cc4e05cd330500379d2394209271b4aeee"
[[package]]
name = "byteorder"
@@ -573,12 +573,12 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.13"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad"
checksum = "cea14ef9355e3beab063703aa9dab15afd25f0667c341310c1e5274bb1d0da18"
dependencies = [
"libc",
"windows-sys 0.60.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -894,7 +894,7 @@ dependencies = [
"tokio",
"tokio-rustls 0.26.2",
"tower-service",
"webpki-roots 1.0.1",
"webpki-roots 1.0.0",
]
[[package]]
@@ -1066,9 +1066,9 @@ checksum = "cb56e1aa765b4b4f3aadfab769793b7087bb03a4ea4920644a6d238e2df5b9ed"
[[package]]
name = "indexmap"
version = "2.10.0"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661"
checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e"
dependencies = [
"equivalent",
"hashbrown",
@@ -1188,9 +1188,9 @@ checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776"
[[package]]
name = "libredox"
version = "0.1.4"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1580801010e535496706ba011c15f8532df6b42297d2e471fec38ceadd8c0638"
checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d"
dependencies = [
"bitflags 2.9.1",
"libc",
@@ -1421,9 +1421,9 @@ checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
[[package]]
name = "owo-colors"
version = "4.2.2"
version = "4.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48dd4f4a2c8405440fd0462561f0e5806bd0f77e86f51c761481bdd4018b545e"
checksum = "26995317201fa17f3656c36716aed4a7c81743a9634ac4c99c0eeda495db0cec"
[[package]]
name = "parking_lot"
@@ -1486,7 +1486,7 @@ dependencies = [
"posthog-rs",
"posthog-symbol-data",
"ratatui",
"reqwest 0.12.22",
"reqwest 0.12.20",
"serde",
"serde_json",
"sha2",
@@ -1595,9 +1595,9 @@ dependencies = [
[[package]]
name = "quinn-udp"
version = "0.5.13"
version = "0.5.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fcebb1209ee276352ef14ff8732e24cc2b02bbac986cd74a4c81bcb2f9881970"
checksum = "ee4e529991f949c5e25755532370b8af5d114acae52326361d68d47af64aa842"
dependencies = [
"cfg_aliases",
"libc",
@@ -1785,9 +1785,9 @@ dependencies = [
[[package]]
name = "reqwest"
version = "0.12.22"
version = "0.12.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cbc931937e6ca3a06e3b6c0aa7841849b160a90351d6ab467a8b9b9959767531"
checksum = "eabf4c97d9130e2bf606614eb937e86edac8292eaa6f422f995d7e8de1eb1813"
dependencies = [
"base64 0.22.1",
"bytes",
@@ -1821,7 +1821,7 @@ dependencies = [
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots 1.0.1",
"webpki-roots 1.0.0",
]
[[package]]
@@ -2195,9 +2195,9 @@ checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2"
[[package]]
name = "syn"
version = "2.0.104"
version = "2.0.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40"
checksum = "e4307e30089d6fd6aff212f2da3a1f9e32f3223b1f010fb09b7c95f90f3ca1e8"
dependencies = [
"proc-macro2",
"quote",
@@ -2269,9 +2269,9 @@ dependencies = [
[[package]]
name = "test-log"
version = "0.2.18"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e33b98a582ea0be1168eba097538ee8dd4bbe0f2b01b22ac92ea30054e5be7b"
checksum = "e7f46083d221181166e5b6f6b1e5f1d499f3a76888826e6cb1d057554157cd0f"
dependencies = [
"env_logger",
"test-log-macros",
@@ -2280,9 +2280,9 @@ dependencies = [
[[package]]
name = "test-log-macros"
version = "0.2.18"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "451b374529930d7601b1eef8d32bc79ae870b6079b069401709c2a8bf9e75f36"
checksum = "888d0c3c6db53c0fdab160d2ed5e12ba745383d3e85813f2ea0f2b1475ab553f"
dependencies = [
"proc-macro2",
"quote",
@@ -2803,9 +2803,9 @@ checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1"
[[package]]
name = "webpki-roots"
version = "1.0.1"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8782dd5a41a24eed3a4f40b606249b3e236ca61adf1f25ea4d45c73de122b502"
checksum = "2853738d1cc4f2da3a225c18ec6c3721abb31961096e9dbf5ab35fa88b19cfdb"
dependencies = [
"rustls-pki-types",
]

View File

@@ -110,3 +110,86 @@ If you've got any requests for Max, including around tools, let us know at #team
- Allow users to both get things done from scratch, and refine what's already there
For a _lot_ of great detail on prompting, check out the [GPT-4.1 prompting guide](https://cookbook.openai.com/examples/gpt4-1_prompting_guide). While somewhat GPT-4.1 specific, those principles largely apply to LLMs overall.
## Support new query types
Max can now read from frontend context multiple query types like trends, funnels, retention, and HogQL queries. To add support for new query types, you need to extend both the QueryExecutor and the Root node.
NOTE: this won't extend query types generation. For that, talk to the Max AI team.
### Adding a new query type
1. **Update the query executor** (`@ee/hogai/graph/query_executor/`):
- Add your new query type to the `SupportedQueryTypes` union in `query_executor.py:33`:
```python
SupportedQueryTypes = (
AssistantTrendsQuery
| TrendsQuery
| AssistantFunnelsQuery
| FunnelsQuery
| AssistantRetentionQuery
| RetentionQuery
| AssistantHogQLQuery
| HogQLQuery
| YourNewQuery # Add your query type
)
```
- Add a new formatter class in `query_executor/format.py` that implements query result formatting for AI consumption (see below, point 3)
- Add formatting logic to `_compress_results()` method in `query_executor/query_executor.py`:
```python
elif isinstance(query, YourNewAssistantQuery | YourNewQuery):
return YourNewResultsFormatter(query, response["results"]).format()
```
- Add example prompts for your query type in `query_executor/prompts.py`, this explains to the LLM the query results formatting
- Update `_get_example_prompt()` method in `query_executor/nodes.py` to handle your new query type:
```python
if isinstance(viz_message.answer, YourNewAssistantQuery):
return YOUR_NEW_EXAMPLE_PROMPT
```
2. **Update the root node** (`@ee/hogai/graph/root/`):
- Add your new query type to the `MAX_SUPPORTED_QUERY_KIND_TO_MODEL` mapping in `nodes.py:57`:
```python
MAX_SUPPORTED_QUERY_KIND_TO_MODEL: dict[str, type[SupportedQueryTypes]] = {
"TrendsQuery": TrendsQuery,
"FunnelsQuery": FunnelsQuery,
"RetentionQuery": RetentionQuery,
"HogQLQuery": HogQLQuery,
"YourNewQuery": YourNewQuery, # Add your query mapping
}
```
3. **Create the formatter class**:
Create a new formatter in `format.py` following the pattern of existing formatters:
```python
class YourNewResultsFormatter:
def __init__(self, query: YourNewQuery, results: dict, team: Optional[Team] = None, utc_now_datetime: Optional[datetime] = None):
self._query = query
self._results = results
self._team = team
self._utc_now_datetime = utc_now_datetime
def format(self) -> str:
# Format your query results for AI consumption
# Return a string representation optimized for LLM understanding
pass
```
4. **Add tests**:
- Add test cases in `test/test_query_executor.py` for your new query type
- Add test cases in `test/test_format.py` for your new formatter
- Ensure tests cover both successful execution and error handling
### Key considerations
- **Query execution**: The `AssistantQueryExecutor` class handles the complete query lifecycle including async polling and error handling
- **Result formatting**: Each query type needs a specialized formatter that converts raw results into AI-readable format
- **Error handling**: The system provides fallback to raw JSON if custom formatting fails
- **Context awareness**: The root node provides UI context (dashboards, insights, events, actions) to help the AI understand the current state
- **Memory integration**: The system can access core memory and onboarding state to provide contextual responses
The query executor is designed to be extensible while maintaining robustness through comprehensive error handling and fallback mechanisms.

View File

@@ -12,7 +12,7 @@ from posthog.schema import (
AssistantToolCall,
HumanMessage,
MaxActionContext,
MaxContextShape,
MaxUIContext,
MaxEventContext,
)
@@ -78,7 +78,7 @@ async def eval_ui_context_actions(call_root_with_ui_context, sample_action):
EvalCase(
input={
"messages": "Show me trends for this action",
"ui_context": MaxContextShape(
"ui_context": MaxUIContext(
actions=[
MaxActionContext(
id=sample_action.id,
@@ -101,7 +101,7 @@ async def eval_ui_context_actions(call_root_with_ui_context, sample_action):
EvalCase(
input={
"messages": "Create a funnel using these actions",
"ui_context": MaxContextShape(
"ui_context": MaxUIContext(
actions=[
MaxActionContext(
id=sample_action.id,
@@ -142,7 +142,7 @@ async def eval_ui_context_events(call_root_with_ui_context):
EvalCase(
input={
"messages": "Show me trends for this event",
"ui_context": MaxContextShape(
"ui_context": MaxUIContext(
events=[
MaxEventContext(
id="1",
@@ -164,7 +164,7 @@ async def eval_ui_context_events(call_root_with_ui_context):
EvalCase(
input={
"messages": "How many users have triggered these events",
"ui_context": MaxContextShape(
"ui_context": MaxUIContext(
events=[
MaxEventContext(
id="1",
@@ -192,7 +192,7 @@ async def eval_ui_context_events(call_root_with_ui_context):
EvalCase(
input={
"messages": "Create a funnel using these event and action",
"ui_context": MaxContextShape(
"ui_context": MaxUIContext(
events=[
MaxEventContext(
id="1",

View File

@@ -12,7 +12,7 @@ from ee.hogai.utils.helpers import find_last_ui_context
from ee.models import Conversation, CoreMemory
from posthog.models import Team
from posthog.models.user import User
from posthog.schema import AssistantMessage, AssistantToolCall, MaxContextShape
from posthog.schema import AssistantMessage, AssistantToolCall, MaxUIContext
from posthog.sync import database_sync_to_async
from ..utils.types import AssistantMessageUnion, AssistantState, PartialAssistantState
@@ -126,7 +126,7 @@ class AssistantNode(ABC):
raise ValueError("Contextual tools must be a dictionary of tool names to tool context")
return contextual_tools
def _get_ui_context(self, state: AssistantState) -> MaxContextShape | None:
def _get_ui_context(self, state: AssistantState) -> MaxUIContext | None:
"""
Extracts the UI context from the latest human message.
"""

View File

@@ -10,7 +10,7 @@ from ee.hogai.utils.types import AssistantState
from posthog.hogql_queries.query_runner import ExecutionMode
from posthog.models import Action
from posthog.models.ai.utils import PgEmbeddingRow, bulk_create_pg_embeddings
from posthog.schema import MaxActionContext, MaxContextShape, TeamTaxonomyQuery
from posthog.schema import MaxActionContext, MaxUIContext, TeamTaxonomyQuery
from posthog.test.base import BaseTest, ClickhouseTestMixin
@@ -89,7 +89,7 @@ class TestInsightRagContextNode(ClickhouseTestMixin, BaseTest):
)
# Mock UI context with actions
mock_ui_context = MaxContextShape(
mock_ui_context = MaxUIContext(
actions=[MaxActionContext(id=context_action.id, name="Context Action", description="From UI Context")]
)
mock_get_ui_context.return_value = mock_ui_context
@@ -117,7 +117,7 @@ class TestInsightRagContextNode(ClickhouseTestMixin, BaseTest):
description="Only from context",
)
mock_ui_context = MaxContextShape(
mock_ui_context = MaxUIContext(
actions=[
MaxActionContext(id=context_action.id, name="Context Only Action", description="Only from context")
]

View File

@@ -36,7 +36,7 @@ from posthog.schema import (
FunnelsQuery,
HogQLQuery,
HumanMessage,
MaxContextShape,
MaxUIContext,
MaxInsightContext,
RetentionQuery,
TrendsQuery,
@@ -73,7 +73,7 @@ T = TypeVar("T", RootMessageUnion, BaseMessage)
class RootNodeUIContextMixin(AssistantNode):
"""Mixin that provides UI context formatting capabilities for root nodes."""
def _format_ui_context(self, ui_context: Optional[MaxContextShape]) -> str:
def _format_ui_context(self, ui_context: Optional[MaxUIContext]) -> str:
"""
Format UI context into template variables for the prompt.

View File

@@ -24,7 +24,7 @@ from posthog.schema import (
HumanMessage,
LifecycleQuery,
MaxActionContext,
MaxContextShape,
MaxUIContext,
MaxDashboardContext,
MaxEventContext,
MaxInsightContext,
@@ -975,7 +975,7 @@ Query results: 42 events
)
# Create mock UI context
ui_context = MaxContextShape(dashboards=[dashboard], insights=None)
ui_context = MaxUIContext(dashboards=[dashboard], insights=None)
result = self.mixin._format_ui_context(ui_context)
@@ -991,7 +991,7 @@ Query results: 42 events
event2 = MaxEventContext(id="2", name="button_click")
# Create mock UI context
ui_context = MaxContextShape(dashboards=None, insights=None, events=[event1, event2], actions=None)
ui_context = MaxUIContext(dashboards=None, insights=None, events=[event1, event2], actions=None)
result = self.mixin._format_ui_context(ui_context)
@@ -1004,7 +1004,7 @@ Query results: 42 events
event2 = MaxEventContext(id="2", name="button_click", description="User clicked a button")
# Create mock UI context
ui_context = MaxContextShape(dashboards=None, insights=None, events=[event1, event2], actions=None)
ui_context = MaxUIContext(dashboards=None, insights=None, events=[event1, event2], actions=None)
result = self.mixin._format_ui_context(ui_context)
@@ -1017,7 +1017,7 @@ Query results: 42 events
action2 = MaxActionContext(id=2.0, name="Purchase")
# Create mock UI context
ui_context = MaxContextShape(dashboards=None, insights=None, events=None, actions=[action1, action2])
ui_context = MaxUIContext(dashboards=None, insights=None, events=None, actions=[action1, action2])
result = self.mixin._format_ui_context(ui_context)
@@ -1030,7 +1030,7 @@ Query results: 42 events
action2 = MaxActionContext(id=2.0, name="Purchase", description="User makes a purchase")
# Create mock UI context
ui_context = MaxContextShape(dashboards=None, insights=None, events=None, actions=[action1, action2])
ui_context = MaxUIContext(dashboards=None, insights=None, events=None, actions=[action1, action2])
result = self.mixin._format_ui_context(ui_context)
@@ -1051,7 +1051,7 @@ Query results: 42 events
)
# Create mock UI context
ui_context = MaxContextShape(insights=[insight])
ui_context = MaxUIContext(insights=[insight])
result = self.mixin._format_ui_context(ui_context)
@@ -1065,7 +1065,7 @@ Query results: 42 events
self.assertEqual(result, "")
# Test with ui_context but no insights
ui_context = MaxContextShape(insights=None)
ui_context = MaxUIContext(insights=None)
result = self.mixin._format_ui_context(ui_context)
self.assertEqual(result, "")
@@ -1083,7 +1083,7 @@ Query results: 42 events
)
# Create mock UI context
ui_context = MaxContextShape(insights=[insight])
ui_context = MaxUIContext(insights=[insight])
result = self.mixin._format_ui_context(ui_context)
@@ -1106,7 +1106,7 @@ Query results: 42 events
)
# Create mock UI context
ui_context = MaxContextShape(insights=[insight])
ui_context = MaxUIContext(insights=[insight])
result = self.mixin._format_ui_context(ui_context)

View File

@@ -45,7 +45,7 @@ from posthog.schema import (
DashboardFilter,
FailureMessage,
HumanMessage,
MaxContextShape,
MaxUIContext,
MaxDashboardContext,
MaxInsightContext,
ReasoningMessage,
@@ -124,7 +124,7 @@ class TestAssistant(ClickhouseTestMixin, NonAtomicBaseTest):
is_new_conversation: bool = False,
mode: AssistantMode = AssistantMode.ASSISTANT,
contextual_tools: Optional[dict[str, Any]] = None,
ui_context: Optional[MaxContextShape] = None,
ui_context: Optional[MaxUIContext] = None,
) -> tuple[list[tuple[str, Any]], Assistant]:
# Create assistant instance with our test graph
assistant = Assistant(
@@ -980,7 +980,7 @@ class TestAssistant(ClickhouseTestMixin, NonAtomicBaseTest):
)
# Test ui_context with multiple fields
ui_context = MaxContextShape(
ui_context = MaxUIContext(
dashboards=[
MaxDashboardContext(
id="1",
@@ -999,7 +999,7 @@ class TestAssistant(ClickhouseTestMixin, NonAtomicBaseTest):
ui_context=ui_context,
)
ui_context_2 = MaxContextShape(insights=[MaxInsightContext(id="3", query=TrendsQuery(series=[]))])
ui_context_2 = MaxUIContext(insights=[MaxInsightContext(id="3", query=TrendsQuery(series=[]))])
# Second run: Create another assistant with the same conversation (simulating retrieval)
output2, assistant2 = await self._run_assistant_graph(

View File

@@ -12,7 +12,7 @@ from posthog.schema import (
AssistantMessage,
AssistantToolCallMessage,
HumanMessage,
MaxContextShape,
MaxUIContext,
VisualizationMessage,
)
@@ -101,7 +101,7 @@ def should_output_assistant_message(candidate_message: AssistantMessageUnion) ->
return True
def find_last_ui_context(messages: Sequence[AssistantMessageUnion]) -> MaxContextShape | None:
def find_last_ui_context(messages: Sequence[AssistantMessageUnion]) -> MaxUIContext | None:
"""Returns the last recorded UI context from all messages."""
for message in reversed(messages):
if isinstance(message, HumanMessage) and message.ui_context is not None:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -154,7 +154,7 @@ import {
UserType,
} from '~/types'
import { MaxContextShape } from '../scenes/max/maxTypes'
import { MaxUIContext } from '../scenes/max/maxTypes'
import { AlertType, AlertTypeWrite } from './components/Alerts/types'
import {
ErrorTrackingStackFrame,
@@ -3539,7 +3539,7 @@ const api = {
/** The user message. Null content means we're continuing previous generation. */
content: string | null
contextual_tools?: Record<string, any>
ui_context?: MaxContextShape
ui_context?: MaxUIContext
conversation?: string | null
trace_id: string
},

View File

@@ -1,5 +1,5 @@
import { hide } from '@floating-ui/react'
import { IconBadge, IconDashboard, IconEye, IconGraph, IconHide, IconInfo } from '@posthog/icons'
import { IconBadge, IconEye, IconHide, IconInfo } from '@posthog/icons'
import { LemonButton, LemonDivider, LemonSegmentedButton, LemonSelect, LemonTag } from '@posthog/lemon-ui'
import { useActions, useValues } from 'kea'
import { ActionPopoverInfo } from 'lib/components/DefinitionPopover/ActionPopoverInfo'
@@ -22,7 +22,6 @@ import { Tooltip } from 'lib/lemon-ui/Tooltip'
import { cn } from 'lib/utils/css-classes'
import { Fragment, useEffect, useMemo } from 'react'
import { DataWarehouseTableForInsight } from 'scenes/data-warehouse/types'
import { MaxContextOption, MaxDashboardContext, MaxInsightContext } from 'scenes/max/maxTypes'
import { isCoreFilter } from '~/taxonomy/helpers'
import { CORE_FILTER_DEFINITIONS_BY_GROUP } from '~/taxonomy/taxonomy'
@@ -374,62 +373,6 @@ function DefinitionView({ group }: { group: TaxonomicFilterGroup }): JSX.Element
</>
)
}
if (group.type === TaxonomicFilterGroupType.MaxAIContext) {
const _definition = definition as MaxContextOption
if (_definition.value !== 'current_page') {
return <></>
}
return (
<>
{sharedComponents}
{_definition.items?.dashboards && _definition.items.dashboards.length > 0 && (
<DefinitionPopover.Section>
<DefinitionPopover.Card
title="Dashboard"
value={
<div className="flex flex-wrap gap-1">
{_definition.items.dashboards.map((dashboard: MaxDashboardContext) => (
<LemonTag
key={dashboard.id}
size="small"
icon={<IconDashboard />}
className="text-xs"
>
{dashboard.name || `Dashboard ${dashboard.id}`}
</LemonTag>
))}
</div>
}
/>
</DefinitionPopover.Section>
)}
{_definition.items?.insights && _definition.items.insights.length > 0 && (
<>
<LemonDivider className="DefinitionPopover my-4" />
<DefinitionPopover.Section>
<DefinitionPopover.Card
title="Insights"
value={
<div className="flex flex-wrap gap-1">
{_definition.items.insights.map((insight: MaxInsightContext) => (
<LemonTag
key={insight.id}
size="small"
icon={<IconGraph />}
className="text-xs"
>
{insight.name || `Insight ${insight.id}`}
</LemonTag>
))}
</div>
}
/>
</DefinitionPopover.Section>
</>
)}
</>
)
}
if (isDataWarehouse) {
const _definition = definition as DataWarehouseTableForInsight
const columnOptions = Object.values(_definition.fields).map((column) => ({

View File

@@ -24,7 +24,6 @@ import { isDefinitionStale } from 'lib/utils/definitions'
import { useState } from 'react'
import { AutoSizer } from 'react-virtualized/dist/es/AutoSizer'
import { List, ListRowProps, ListRowRenderer } from 'react-virtualized/dist/es/List'
import { MaxContextOption } from 'scenes/max/maxTypes'
import { EventDefinition, PropertyDefinition } from '~/types'
@@ -150,7 +149,7 @@ const selectedItemHasPopover = (
return (
// NB: also update "renderItemContents" above
TaxonomicFilterGroupType.EventMetadata,
(!!item &&
!!item &&
!!group?.getValue?.(item) &&
!!listGroupType &&
([
@@ -172,10 +171,7 @@ const selectedItemHasPopover = (
TaxonomicFilterGroupType.Metadata,
TaxonomicFilterGroupType.SessionProperties,
].includes(listGroupType) ||
listGroupType.startsWith(TaxonomicFilterGroupType.GroupsPrefix))) ||
(!!item &&
listGroupType === TaxonomicFilterGroupType.MaxAIContext &&
(item as MaxContextOption).value === 'current_page')
listGroupType.startsWith(TaxonomicFilterGroupType.GroupsPrefix))
)
}

View File

@@ -25,7 +25,7 @@ import {
import { dataWarehouseJoinsLogic } from 'scenes/data-warehouse/external/dataWarehouseJoinsLogic'
import { dataWarehouseSceneLogic } from 'scenes/data-warehouse/settings/dataWarehouseSceneLogic'
import { experimentsLogic } from 'scenes/experiments/experimentsLogic'
import { MaxContextOption } from 'scenes/max/maxTypes'
import { MaxContextTaxonomicFilterOption } from 'scenes/max/maxTypes'
import { groupDisplayId } from 'scenes/persons/GroupActorDisplay'
import { projectLogic } from 'scenes/projectLogic'
import { ReplayTaxonomicFilters } from 'scenes/session-recordings/filters/ReplayTaxonomicFilters'
@@ -231,7 +231,7 @@ export const taxonomicFilterLogic = kea<taxonomicFilterLogicType>([
propertyFilters: { excludedProperties: any; propertyAllowList: any },
eventMetadataPropertyDefinitions: PropertyDefinition[],
eventOrdering: string | null,
maxContextOptions: MaxContextOption[]
maxContextOptions: MaxContextTaxonomicFilterOption[]
): TaxonomicFilterGroup[] => {
const { excludedProperties, propertyAllowList } = propertyFilters
const groups: TaxonomicFilterGroup[] = [
@@ -683,9 +683,9 @@ export const taxonomicFilterLogic = kea<taxonomicFilterLogicType>([
searchPlaceholder: 'elements from this page',
type: TaxonomicFilterGroupType.MaxAIContext,
options: maxContextOptions,
getName: (option: MaxContextOption) => option.name,
getValue: (option: MaxContextOption) => option.value,
getIcon: (option: MaxContextOption) => {
getName: (option: MaxContextTaxonomicFilterOption) => option.name,
getValue: (option: MaxContextTaxonomicFilterOption) => option.value,
getIcon: (option: MaxContextTaxonomicFilterOption) => {
const Icon = option.icon as React.ComponentType
if (Icon) {
return <Icon />

View File

@@ -2,7 +2,7 @@ import Fuse from 'fuse.js'
import { LogicWrapper } from 'kea'
import { DataWarehouseTableForInsight } from 'scenes/data-warehouse/types'
import { LocalFilter } from 'scenes/insights/filters/ActionFilter/entityFilterLogic'
import { MaxContextOption } from 'scenes/max/maxTypes'
import { MaxContextTaxonomicFilterOption } from 'scenes/max/maxTypes'
import { AnyDataNode, DatabaseSchemaField, DatabaseSerializedFieldType } from '~/queries/schema/schema-general'
import {
@@ -47,7 +47,7 @@ export interface TaxonomicFilterProps {
hideBehavioralCohorts?: boolean
showNumericalPropsOnly?: boolean
dataWarehousePopoverFields?: DataWarehousePopoverField[]
maxContextOptions?: MaxContextOption[]
maxContextOptions?: MaxContextTaxonomicFilterOption[]
/**
* Controls the layout of taxonomic groups.
* When undefined (default), vertical/columnar layout is automatically used when there are more than VERTICAL_LAYOUT_THRESHOLD (4) groups.
@@ -186,4 +186,4 @@ export type TaxonomicDefinitionTypes =
| ActionType
| PersonProperty
| DataWarehouseTableForInsight
| MaxContextOption
| MaxContextTaxonomicFilterOption

View File

@@ -9,7 +9,7 @@ import { LemonButton, LemonButtonProps } from 'lib/lemon-ui/LemonButton'
import { LemonDropdown } from 'lib/lemon-ui/LemonDropdown'
import { forwardRef, Ref, useEffect, useState } from 'react'
import { LocalFilter } from 'scenes/insights/filters/ActionFilter/entityFilterLogic'
import { MaxContextOption } from 'scenes/max/maxTypes'
import { MaxContextTaxonomicFilterOption } from 'scenes/max/maxTypes'
import { AnyDataNode, DatabaseSchemaField } from '~/queries/schema/schema-general'
@@ -34,7 +34,7 @@ export interface TaxonomicPopoverProps<ValueType extends TaxonomicFilterValue =
metadataSource?: AnyDataNode
showNumericalPropsOnly?: boolean
dataWarehousePopoverFields?: DataWarehousePopoverField[]
maxContextOptions?: MaxContextOption[]
maxContextOptions?: MaxContextTaxonomicFilterOption[]
}
/** Like TaxonomicPopover, but convenient when you know you will only use string values */

View File

@@ -12817,7 +12817,7 @@
"type": "string"
},
"ui_context": {
"$ref": "#/definitions/MaxContextShape"
"$ref": "#/definitions/MaxUIContext"
}
},
"required": ["type", "content"],
@@ -14469,62 +14469,27 @@
"additionalProperties": false,
"properties": {
"description": {
"type": "string"
"type": ["string", "null"]
},
"id": {
"type": "number"
},
"name": {
"type": "string"
},
"type": {
"const": "action",
"type": "string"
}
},
"required": ["id", "name"],
"type": "object"
},
"MaxContextShape": {
"additionalProperties": false,
"properties": {
"actions": {
"items": {
"$ref": "#/definitions/MaxActionContext"
},
"type": "array"
},
"dashboards": {
"items": {
"$ref": "#/definitions/MaxDashboardContext"
},
"type": "array"
},
"events": {
"items": {
"$ref": "#/definitions/MaxEventContext"
},
"type": "array"
},
"filters_override": {
"$ref": "#/definitions/DashboardFilter"
},
"insights": {
"items": {
"$ref": "#/definitions/MaxInsightContext"
},
"type": "array"
},
"variables_override": {
"additionalProperties": {
"$ref": "#/definitions/HogQLVariable"
},
"type": "object"
}
},
"required": ["type", "id", "name"],
"type": "object"
},
"MaxDashboardContext": {
"additionalProperties": false,
"properties": {
"description": {
"type": "string"
"type": ["string", "null"]
},
"filters": {
"$ref": "#/definitions/DashboardFilter"
@@ -14539,26 +14504,34 @@
"type": "array"
},
"name": {
"type": ["string", "null"]
},
"type": {
"const": "dashboard",
"type": "string"
}
},
"required": ["id", "insights", "filters"],
"required": ["type", "id", "insights", "filters"],
"type": "object"
},
"MaxEventContext": {
"additionalProperties": false,
"properties": {
"description": {
"type": "string"
"type": ["string", "null"]
},
"id": {
"type": "string"
},
"name": {
"type": ["string", "null"]
},
"type": {
"const": "event",
"type": "string"
}
},
"required": ["id"],
"required": ["type", "id"],
"type": "object"
},
"MaxInnerUniversalFiltersGroup": {
@@ -14581,19 +14554,23 @@
"additionalProperties": false,
"properties": {
"description": {
"type": "string"
"type": ["string", "null"]
},
"id": {
"$ref": "#/definitions/InsightShortId"
},
"name": {
"type": "string"
"type": ["string", "null"]
},
"query": {
"$ref": "#/definitions/QuerySchema"
},
"type": {
"const": "insight",
"type": "string"
}
},
"required": ["id", "query"],
"required": ["type", "id", "query"],
"type": "object"
},
"MaxOuterUniversalFiltersGroup": {
@@ -14641,6 +14618,45 @@
"required": ["duration", "filter_group"],
"type": "object"
},
"MaxUIContext": {
"additionalProperties": false,
"properties": {
"actions": {
"items": {
"$ref": "#/definitions/MaxActionContext"
},
"type": "array"
},
"dashboards": {
"items": {
"$ref": "#/definitions/MaxDashboardContext"
},
"type": "array"
},
"events": {
"items": {
"$ref": "#/definitions/MaxEventContext"
},
"type": "array"
},
"filters_override": {
"$ref": "#/definitions/DashboardFilter"
},
"insights": {
"items": {
"$ref": "#/definitions/MaxInsightContext"
},
"type": "array"
},
"variables_override": {
"additionalProperties": {
"$ref": "#/definitions/HogQLVariable"
},
"type": "object"
}
},
"type": "object"
},
"MaxUniversalFilterValue": {
"anyOf": [
{

View File

@@ -1,4 +1,4 @@
import type { MaxContextShape } from 'scenes/max/maxTypes'
import type { MaxUIContext } from 'scenes/max/maxTypes'
import {
AssistantFunnelsQuery,
@@ -23,7 +23,7 @@ export interface BaseAssistantMessage {
export interface HumanMessage extends BaseAssistantMessage {
type: AssistantMessageType.Human
content: string
ui_context?: MaxContextShape
ui_context?: MaxUIContext
}
export interface AssistantFormOption {

View File

@@ -16,7 +16,7 @@ import uniqBy from 'lodash.uniqby'
import { Layout, Layouts } from 'react-grid-layout'
import { calculateLayouts } from 'scenes/dashboard/tileLayouts'
import { dataThemeLogic } from 'scenes/dataThemeLogic'
import { maxContextLogic } from 'scenes/max/maxContextLogic'
import { createMaxContextHelpers, MaxContextInput } from 'scenes/max/maxTypes'
import { Scene } from 'scenes/sceneTypes'
import { urls } from 'scenes/urls'
import { userLogic } from 'scenes/userLogic'
@@ -104,7 +104,7 @@ export const dashboardLogic = kea<dashboardLogicType>([
dataThemeLogic,
['getTheme'],
],
logic: [dashboardsModel, insightsModel, eventUsageLogic, variableDataLogic, maxContextLogic],
logic: [dashboardsModel, insightsModel, eventUsageLogic, variableDataLogic],
})),
props({} as DashboardLogicProps),
@@ -1165,6 +1165,16 @@ export const dashboardLogic = kea<dashboardLogicType>([
],
// NOTE: noCache is used to prevent the dashboard from using cached results from previous loads when url variables override
noCache: [(s) => [s.urlVariables], (urlVariables) => Object.keys(urlVariables).length > 0],
maxContext: [
(s) => [s.dashboard],
(dashboard): MaxContextInput[] => {
if (!dashboard) {
return []
}
return [createMaxContextHelpers.dashboard(dashboard)]
},
],
})),
events(({ actions, cache, props }) => ({
afterMount: () => {
@@ -1189,8 +1199,6 @@ export const dashboardLogic = kea<dashboardLogicType>([
window.clearInterval(cache.autoRefreshInterval)
cache.autoRefreshInterval = null
}
// Clear dashboard context when unmounting
maxContextLogic.actions.clearActiveDashboard()
},
})),
sharedListeners(({ values, props }) => ({
@@ -1495,9 +1503,6 @@ export const dashboardLogic = kea<dashboardLogicType>([
return // We hit a 404
}
// Set dashboard context for Max AI
maxContextLogic.actions.setActiveDashboard(values.dashboard)
// access stored values from dashboardLoadData
// as we can't pass them down to this listener
const { action, manualDashboardRefresh } = values.dashboardLoadData

View File

@@ -3,7 +3,6 @@ import { actionToUrl, router } from 'kea-router'
import { objectsEqual } from 'lib/utils'
import { DATAWAREHOUSE_EDITOR_ITEM_ID } from 'scenes/data-warehouse/utils'
import { keyForInsightLogicProps } from 'scenes/insights/sharedUtils'
import { maxContextLogic } from 'scenes/max/maxContextLogic'
import { Scene } from 'scenes/sceneTypes'
import { filterTestAccountsDefaultsLogic } from 'scenes/settings/environment/filterTestAccountDefaultsLogic'
@@ -60,11 +59,11 @@ export const insightDataLogic = kea<insightDataLogicType>([
],
actions: [
insightLogic,
['setInsight', 'setMaxContext'],
['setInsight'],
dataNodeLogic({ key: insightVizDataNodeKey(props) } as DataNodeLogicProps),
['loadData', 'loadDataSuccess', 'loadDataFailure', 'setResponse as setInsightData'],
],
logic: [insightDataTimingLogic(props), insightUsageLogic(props), maxContextLogic],
logic: [insightDataTimingLogic(props), insightUsageLogic(props)],
})),
actions({
@@ -216,9 +215,6 @@ export const insightDataLogic = kea<insightDataLogicType>([
actions.setInsightData({ ...values.insightData, result: savedResult ? savedResult : null })
},
setQuery: ({ query }) => {
// Update MaxAI context when query changes
actions.setMaxContext()
// if the query is not changed, don't save it
if (!query || !values.queryChanged) {
return

View File

@@ -4,7 +4,6 @@ import api from 'lib/api'
import { MOCK_DEFAULT_TEAM, MOCK_TEAM_ID } from 'lib/api.mock'
import { DashboardPrivilegeLevel, DashboardRestrictionLevel } from 'lib/constants'
import { dashboardLogic } from 'scenes/dashboard/dashboardLogic'
import { maxContextLogic } from 'scenes/max/maxContextLogic'
import { savedInsightsLogic } from 'scenes/saved-insights/savedInsightsLogic'
import { sceneLogic } from 'scenes/sceneLogic'
import { teamLogic } from 'scenes/teamLogic'
@@ -761,99 +760,4 @@ describe('insightLogic', () => {
])
})
})
describe('maxContext integration', () => {
beforeEach(async () => {
const insightProps: InsightLogicProps = { dashboardItemId: 'new' }
logic = insightLogic(insightProps)
logic.mount()
insightDataLogic(insightProps).mount()
})
it('calls setMaxContext when insight changes via subscription', async () => {
const testInsight = {
id: 42,
short_id: Insight42,
name: 'Test Insight',
query: { kind: NodeKind.DataTableNode },
}
await expectLogic(logic, () => {
logic.actions.setInsight(testInsight, { overrideQuery: true })
})
.toDispatchActions(['setInsight', 'setMaxContext'])
.toFinishAllListeners()
})
it('calls maxContextLogic.addOrUpdateActiveInsight with correct parameters when setMaxContext is triggered', async () => {
const testInsight = {
id: 42,
short_id: Insight42,
name: 'Test Insight',
query: { kind: NodeKind.DataTableNode },
}
// Set up the insight first
logic.actions.setInsight(testInsight, { overrideQuery: true })
// Mock the maxContextLogic actions
const mockAddOrUpdateActiveInsight = jest.fn()
jest.spyOn(maxContextLogic, 'findMounted').mockReturnValue({
actions: {
addOrUpdateActiveInsight: mockAddOrUpdateActiveInsight,
},
} as any)
await expectLogic(logic, () => {
logic.actions.setMaxContext()
})
.toDispatchActions(['setMaxContext'])
.toFinishAllListeners()
// Verify that addOrUpdateActiveInsight was called with the correct parameters
expect(mockAddOrUpdateActiveInsight).toHaveBeenCalledWith(
expect.objectContaining({
id: 42,
short_id: Insight42,
name: 'Test Insight',
query: { kind: NodeKind.DataTableNode },
}),
false
)
})
it('does not call maxContextLogic.addOrUpdateActiveInsight when insight has no query', async () => {
const testInsight = {
id: 42,
short_id: Insight42,
name: 'Test Insight',
query: null,
}
// Set up the insight first
logic.actions.setInsight(testInsight, { overrideQuery: true })
// Mock the maxContextLogic actions
const mockAddOrUpdateActiveInsight = jest.fn()
jest.spyOn(maxContextLogic, 'findMounted').mockReturnValue({
actions: {
addOrUpdateActiveInsight: mockAddOrUpdateActiveInsight,
},
} as any)
await expectLogic(logic, () => {
logic.actions.setMaxContext()
})
.toDispatchActions(['setMaxContext'])
.toFinishAllListeners()
// Verify that addOrUpdateActiveInsight was NOT called because there's no query
expect(mockAddOrUpdateActiveInsight).not.toHaveBeenCalled()
})
afterEach(() => {
jest.restoreAllMocks()
})
})
})

View File

@@ -2,7 +2,6 @@ import { LemonDialog, LemonInput } from '@posthog/lemon-ui'
import { actions, connect, events, kea, key, listeners, LogicWrapper, path, props, reducers, selectors } from 'kea'
import { loaders } from 'kea-loaders'
import { router } from 'kea-router'
import { subscriptions } from 'kea-subscriptions'
import { accessLevelSatisfied } from 'lib/components/AccessControlAction'
import { FEATURE_FLAGS } from 'lib/constants'
import { LemonField } from 'lib/lemon-ui/LemonField'
@@ -14,7 +13,6 @@ import { dashboardLogic } from 'scenes/dashboard/dashboardLogic'
import { insightSceneLogic } from 'scenes/insights/insightSceneLogic'
import { keyForInsightLogicProps } from 'scenes/insights/sharedUtils'
import { summarizeInsight } from 'scenes/insights/summarizeInsight'
import { maxContextLogic } from 'scenes/max/maxContextLogic'
import { savedInsightsLogic } from 'scenes/saved-insights/savedInsightsLogic'
import { sceneLogic } from 'scenes/sceneLogic'
import { Scene } from 'scenes/sceneTypes'
@@ -125,7 +123,6 @@ export const insightLogic: LogicWrapper<insightLogicType> = kea<insightLogicType
}),
highlightSeries: (seriesIndex: number | null) => ({ seriesIndex }),
setAccessDeniedToInsight: true,
setMaxContext: true,
}),
loaders(({ actions, values, props }) => ({
insight: [
@@ -501,12 +498,6 @@ export const insightLogic: LogicWrapper<insightLogicType> = kea<insightLogicType
router.actions.push(urls.insightEdit(insight.short_id))
}
},
setMaxContext: () => {
// Set MaxAI context when insight changes
if (values.insight && values.insight.id && values.insight.query) {
maxContextLogic.findMounted()?.actions.addOrUpdateActiveInsight(values.insight, values.isInViewMode)
}
},
})),
events(({ props, actions }) => ({
afterMount: () => {
@@ -523,9 +514,4 @@ export const insightLogic: LogicWrapper<insightLogicType> = kea<insightLogicType
}
},
})),
subscriptions(({ actions }) => ({
insight: () => {
actions.setMaxContext()
},
})),
])

View File

@@ -26,6 +26,7 @@ import {
InsightType,
ItemMode,
ProjectTreeRef,
QueryBasedInsightModel,
} from '~/types'
import { insightDataLogic } from './insightDataLogic'
@@ -35,6 +36,8 @@ import { parseDraftQueryFromLocalStorage, parseDraftQueryFromURL } from './utils
import api from 'lib/api'
import { checkLatestVersionsOnQuery } from '~/queries/utils'
import { MaxContextInput, createMaxContextHelpers } from 'scenes/max/maxTypes'
const NEW_INSIGHT = 'new' as const
export type InsightId = InsightShortId | typeof NEW_INSIGHT | null
@@ -233,6 +236,15 @@ export const insightSceneLogic = kea<insightSceneLogicType>([
: null
},
],
maxContext: [
(s) => [s.insight],
(insight: Partial<QueryBasedInsightModel>): MaxContextInput[] => {
if (!insight || !insight.short_id || !insight.query) {
return []
}
return [createMaxContextHelpers.insight(insight)]
},
],
})),
sharedListeners(({ actions, values }) => ({
reloadInsightLogic: () => {

View File

@@ -14,6 +14,12 @@ function pluralize(count: number, word: string): string {
return `${count} ${word}${count > 1 ? 's' : ''}`
}
interface ContextTagItem {
type: string
name: string
icon: React.ReactElement
}
interface ContextSummaryProps {
insights?: MaxInsightContext[]
dashboards?: MaxDashboardContext[]
@@ -75,11 +81,7 @@ export function ContextSummary({
}, [contextCounts])
const allItems = useMemo(() => {
const items = []
if (useCurrentPageContext) {
items.push({ type: 'current-page', name: 'Current page', icon: <IconPageChart /> })
}
const items: ContextTagItem[] = []
if (dashboards) {
dashboards.forEach((dashboard) => {
@@ -122,7 +124,7 @@ export function ContextSummary({
}
return items
}, [useCurrentPageContext, dashboards, insights, events, actions])
}, [dashboards, insights, events, actions])
if (totalCount === 0) {
return null
@@ -150,35 +152,13 @@ export function ContextSummary({
}
export function ContextTags({ size = 'default' }: { size?: 'small' | 'default' }): JSX.Element | null {
const { contextInsights, contextDashboards, contextEvents, contextActions, useCurrentPageContext } =
useValues(maxContextLogic)
const {
removeContextInsight,
removeContextDashboard,
removeContextEvent,
removeContextAction,
disableCurrentPageContext,
} = useActions(maxContextLogic)
const { contextInsights, contextDashboards, contextEvents, contextActions } = useValues(maxContextLogic)
const { removeContextInsight, removeContextDashboard, removeContextEvent, removeContextAction } =
useActions(maxContextLogic)
const allTags = useMemo(() => {
const tags: JSX.Element[] = []
// Current page context
if (useCurrentPageContext) {
tags.push(
<LemonTag
key="current-page"
icon={<IconPageChart className="flex-shrink-0" />}
onClose={disableCurrentPageContext}
closable
closeOnClick
className={clsx('flex items-center', size === 'small' ? 'max-w-20' : 'max-w-48')}
>
<span className="truncate min-w-0 flex-1">Current page</span>
</LemonTag>
)
}
// Context items configuration
const contextConfigs = [
{
@@ -237,7 +217,6 @@ export function ContextTags({ size = 'default' }: { size?: 'small' | 'default' }
return tags
}, [
size,
useCurrentPageContext,
contextDashboards,
contextInsights,
contextEvents,
@@ -246,7 +225,6 @@ export function ContextTags({ size = 'default' }: { size?: 'small' | 'default' }
removeContextInsight,
removeContextEvent,
removeContextAction,
disableCurrentPageContext,
])
if (allTags.length === 0) {

View File

@@ -491,8 +491,7 @@ export const ThreadWithMultipleContextObjects: StoryFn = () => {
},
})
const { addOrUpdateContextInsight, enableCurrentPageContext, addOrUpdateActiveInsight } =
useActions(maxContextLogic)
const { addOrUpdateContextInsight } = useActions(maxContextLogic)
useEffect(() => {
// Add multiple context insights
@@ -515,24 +514,7 @@ export const ThreadWithMultipleContextObjects: StoryFn = () => {
series: [{ event: 'sign up' }, { event: 'first action' }],
} as FunnelsQuery,
})
// Add active insights for current page context
addOrUpdateActiveInsight(
{
short_id: 'active-insight-1' as InsightShortId,
name: 'Current Page Metrics',
description: 'Metrics for the current page',
query: {
kind: 'TrendsQuery',
series: [{ event: '$pageview' }],
} as TrendsQuery,
},
false
)
// Enable current page context to show active insights/dashboard
enableCurrentPageContext()
}, [addOrUpdateActiveInsight, addOrUpdateContextInsight, enableCurrentPageContext])
}, [addOrUpdateContextInsight])
return <Template sidePanel />
}

View File

@@ -1,28 +1,18 @@
import { BindLogic, useActions, useValues } from 'kea'
import { FEATURE_FLAGS } from 'lib/constants'
import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
import clsx from 'clsx'
import { sidePanelLogic } from '~/layout/navigation-3000/sidepanel/sidePanelLogic'
import { ExpandedFloatingMax } from './components/ExpandedFloatingMax'
import { CollapsedFloatingMax } from './components/CollapsedFloatingMax'
import { maxGlobalLogic } from './maxGlobalLogic'
import { maxLogic } from './maxLogic'
import { maxThreadLogic, MaxThreadLogicProps } from './maxThreadLogic'
import './MaxFloatingInput.scss'
import { sceneLogic } from 'scenes/sceneLogic'
import { Scene } from 'scenes/sceneTypes'
import { SidePanelTab } from '~/types'
import { useFloatingMaxPosition } from './utils/floatingMaxPositioning'
export function MaxFloatingInput(): JSX.Element | null {
const { featureFlags } = useValues(featureFlagLogic)
const { sidePanelOpen, selectedTab } = useValues(sidePanelLogic)
const { scene, sceneConfig } = useValues(sceneLogic)
const { threadLogicKey, conversation } = useValues(maxLogic)
const { isFloatingMaxExpanded, floatingMaxDragState } = useValues(maxGlobalLogic)
const { isFloatingMaxExpanded, floatingMaxDragState, showFloatingMax } = useValues(maxGlobalLogic)
const { setFloatingMaxPosition } = useActions(maxGlobalLogic)
const { floatingMaxPositionStyle } = useFloatingMaxPosition()
@@ -46,19 +36,7 @@ export function MaxFloatingInput(): JSX.Element | null {
startNewConversation()
}
if (!featureFlags[FEATURE_FLAGS.ARTIFICIAL_HOG] || !featureFlags[FEATURE_FLAGS.FLOATING_ARTIFICIAL_HOG]) {
return null
}
// Hide floating Max IF:
if (
(scene === Scene.Max && !isFloatingMaxExpanded) || // In the full Max scene, and Max is not intentionally in floating mode (i.e. expanded)
(sidePanelOpen && selectedTab === SidePanelTab.Max) // The Max side panel is open
) {
return null
}
if (sceneConfig?.layout === 'plain') {
if (!showFloatingMax) {
return null
}

View File

@@ -0,0 +1,60 @@
### Max Context
Scene logics can expose a `maxContext` selector to provide relevant context to MaxAI.
To do so:
1. Import the necessary types and helpers:
```typescript
import { MaxContextInput, createMaxContextHelpers } from 'scenes/max/maxTypes'
```
2. Add a `maxContext` selector that returns MaxContextInput[]:
```typescript
selectors({
maxContext: [
(s) => [s.dashboard],
(dashboard): MaxContextInput[] => {
if (!dashboard) {
return []
}
return [createMaxContextHelpers.dashboard(dashboard)]
},
],
})
```
3. For multiple context items:
```typescript
maxContext: [
(s) => [s.insight, s.events],
(insight, events): MaxContextInput[] => {
const context = []
if (insight) context.push(createMaxContextHelpers.insight(insight))
if (events?.length) context.push(...events.map(createMaxContextHelpers.event))
return context
},
]
```
The maxContextLogic will automatically detect and process these context items.
Use the helper functions to ensure type safety and consistency.
Currently, these context entities are supported:
- Dashboards
- Insights
- Events
- Actions
If you want to add new entities, you need to extend `maxContextLogic.ts`, slightly more difficult, but doable, check how other entities are supported and start from there.
Caveat: we currently support these types of insights: trends, funnels, retention, custom SQL.
This means that if you expose a dashboard with custom queries, these will show up in the frontend logic,
but won't be actually available to Max in the backend.
To add support for **reading** custom queries, refer to the README in ee/hogai
To add support for **generating** insights with custom queries, talk to the Max AI team

View File

@@ -1,7 +1,9 @@
import { IconDashboard, IconGraph, IconPageChart } from '@posthog/icons'
import {} from '@posthog/icons'
import { router } from 'kea-router'
import { expectLogic, partial } from 'kea-test-utils'
import { TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types'
import { dashboardLogic } from 'scenes/dashboard/dashboardLogic'
import { insightLogic } from 'scenes/insights/insightLogic'
import { useMocks } from '~/mocks/jest'
import { initKeaTests } from '~/test/init'
@@ -35,6 +37,7 @@ describe('maxContextLogic', () => {
name: 'Test Insight',
description: 'Test insight description',
query: { kind: 'TrendsQuery' },
type: 'insight',
}
const mockDashboard: DashboardType<QueryBasedInsightModel> = {
@@ -55,12 +58,26 @@ describe('maxContextLogic', () => {
description: 'Test event description',
} as EventDefinition
const expectedTransformedEvent = {
id: 'event-1',
name: 'Test Event',
description: 'Test event description',
type: 'event',
}
const mockAction: ActionType = {
id: 1,
name: 'Test Action',
description: 'Test action description',
} as ActionType
const expectedTransformedAction = {
id: 1,
name: 'Test Action',
description: 'Test action description',
type: 'action',
}
// Create the expected transformed dashboard
const expectedTransformedDashboard = {
id: 1,
@@ -68,6 +85,7 @@ describe('maxContextLogic', () => {
description: 'Test dashboard description',
insights: [expectedTransformedInsight],
filters: mockDashboard.filters,
type: 'dashboard',
}
beforeEach(() => {
@@ -82,86 +100,38 @@ describe('maxContextLogic', () => {
})
describe('core functionality', () => {
it('manages current page context', async () => {
await expectLogic(logic).toMatchValues({
useCurrentPageContext: false,
})
await expectLogic(logic, () => {
logic.actions.enableCurrentPageContext()
}).toMatchValues({
useCurrentPageContext: true,
})
await expectLogic(logic, () => {
logic.actions.disableCurrentPageContext()
}).toMatchValues({
useCurrentPageContext: false,
})
})
it('manages context data', async () => {
await expectLogic(logic).toMatchValues({
contextInsights: [],
contextDashboards: [],
contextEvents: [],
contextActions: [],
})
logic.actions.addOrUpdateContextInsight(mockInsight)
logic.actions.addOrUpdateContextInsight(mockInsight as any)
logic.actions.addOrUpdateContextDashboard(mockDashboard)
logic.actions.addOrUpdateContextEvent(mockEvent)
logic.actions.addOrUpdateContextAction(mockAction)
await expectLogic(logic).toMatchValues({
contextInsights: [expectedTransformedInsight],
contextDashboards: [expectedTransformedDashboard],
})
})
it('manages active insights', async () => {
await expectLogic(logic).toMatchValues({
activeInsights: [],
})
logic.actions.addOrUpdateActiveInsight(mockInsight, false)
await expectLogic(logic).toMatchValues({
activeInsights: [expectedTransformedInsight],
})
logic.actions.clearActiveInsights()
await expectLogic(logic).toMatchValues({
activeInsights: [],
})
})
it('manages active dashboard', async () => {
await expectLogic(logic).toMatchValues({
activeDashboard: null,
})
logic.actions.setActiveDashboard(mockDashboard)
await expectLogic(logic).toMatchValues({
activeDashboard: expectedTransformedDashboard,
})
logic.actions.clearActiveDashboard()
await expectLogic(logic).toMatchValues({
activeDashboard: null,
contextEvents: [expectedTransformedEvent],
contextActions: [expectedTransformedAction],
})
})
it('resets all context', async () => {
logic.actions.addOrUpdateContextInsight(mockInsight)
logic.actions.addOrUpdateContextInsight(mockInsight as any)
logic.actions.addOrUpdateContextDashboard(mockDashboard)
logic.actions.addOrUpdateContextEvent(mockEvent)
logic.actions.addOrUpdateContextAction(mockAction)
logic.actions.enableCurrentPageContext()
await expectLogic(logic).toMatchValues({
contextInsights: [expectedTransformedInsight],
contextDashboards: [expectedTransformedDashboard],
useCurrentPageContext: true,
contextEvents: [expectedTransformedEvent],
contextActions: [expectedTransformedAction],
})
logic.actions.resetContext()
@@ -171,7 +141,6 @@ describe('maxContextLogic', () => {
contextDashboards: [],
contextEvents: [],
contextActions: [],
useCurrentPageContext: false,
})
})
})
@@ -182,18 +151,16 @@ describe('maxContextLogic', () => {
hasData: false,
})
logic.actions.addOrUpdateContextInsight(mockInsight)
logic.actions.addOrUpdateContextInsight(mockInsight as any)
await expectLogic(logic).toMatchValues({
hasData: true,
})
logic.actions.removeContextInsight('test')
logic.actions.addOrUpdateActiveInsight(mockInsight, false)
logic.actions.enableCurrentPageContext()
logic.actions.removeContextInsight('insight-1')
await expectLogic(logic).toMatchValues({
hasData: true,
hasData: false,
})
})
@@ -202,61 +169,8 @@ describe('maxContextLogic', () => {
contextOptions: [],
})
logic.actions.addOrUpdateActiveInsight(mockInsight, false)
logic.actions.enableCurrentPageContext()
await expectLogic(logic).toMatchValues({
contextOptions: [
{
id: 'current_page',
name: 'Current page',
value: 'current_page',
icon: IconPageChart,
items: {
insights: [expectedTransformedInsight],
dashboards: [],
},
},
{
id: 'insight-1',
name: 'Test Insight',
value: 'insight-1',
type: 'insight',
icon: IconGraph,
},
],
})
logic.actions.setActiveDashboard(mockDashboard)
await expectLogic(logic).toMatchValues({
contextOptions: [
{
id: 'current_page',
name: 'Current page',
value: 'current_page',
icon: IconPageChart,
items: {
insights: [expectedTransformedInsight],
dashboards: [expectedTransformedDashboard],
},
},
{
id: '1',
name: 'Test Dashboard',
value: 1,
type: 'dashboard',
icon: IconDashboard,
},
{
id: 'insight-1',
name: 'Test Insight',
value: 'insight-1',
type: 'insight',
icon: IconGraph,
},
],
})
// Since contextOptions now come from sceneContext, we can't test directly
// This test would need to be updated to work with scene-based context
})
it('calculates taxonomic group types correctly', async () => {
@@ -270,19 +184,8 @@ describe('maxContextLogic', () => {
],
})
logic.actions.addOrUpdateActiveInsight(mockInsight, false)
logic.actions.enableCurrentPageContext()
await expectLogic(logic).toMatchValues({
mainTaxonomicGroupType: TaxonomicFilterGroupType.MaxAIContext,
taxonomicGroupTypes: [
TaxonomicFilterGroupType.MaxAIContext,
TaxonomicFilterGroupType.Events,
TaxonomicFilterGroupType.Actions,
TaxonomicFilterGroupType.Insights,
TaxonomicFilterGroupType.Dashboards,
],
})
// Test would require scene context to have context options
// Since contextOptions now come from sceneContext which is computed automatically
})
it('compiles context correctly', async () => {
@@ -292,7 +195,7 @@ describe('maxContextLogic', () => {
short_id: 'context-insight-1' as any,
}
logic.actions.addOrUpdateContextInsight(contextInsight)
logic.actions.addOrUpdateContextInsight(contextInsight as any)
logic.actions.addOrUpdateContextDashboard(mockDashboard)
logic.actions.addOrUpdateContextEvent(mockEvent)
logic.actions.addOrUpdateContextAction(mockAction)
@@ -305,6 +208,7 @@ describe('maxContextLogic', () => {
name: 'Test Insight',
description: 'Test insight description',
query: { kind: 'TrendsQuery' },
type: 'insight',
},
],
dashboards: [
@@ -318,8 +222,11 @@ describe('maxContextLogic', () => {
name: 'Test Insight',
description: 'Test insight description',
query: { kind: 'TrendsQuery' },
type: 'insight',
},
],
filters: mockDashboard.filters,
type: 'dashboard',
},
],
events: [
@@ -327,6 +234,7 @@ describe('maxContextLogic', () => {
id: 'event-1',
name: 'Test Event',
description: 'Test event description',
type: 'event',
},
],
actions: [
@@ -334,6 +242,7 @@ describe('maxContextLogic', () => {
id: 1,
name: 'Test Action',
description: 'Test action description',
type: 'action',
},
],
}),
@@ -341,7 +250,7 @@ describe('maxContextLogic', () => {
})
it('does not include both insights and dashboard insights when they have same IDs', async () => {
logic.actions.addOrUpdateContextInsight(mockInsight)
logic.actions.addOrUpdateContextInsight(mockInsight as any)
logic.actions.addOrUpdateContextDashboard(mockDashboard)
await expectLogic(logic).toMatchValues({
@@ -355,10 +264,8 @@ describe('maxContextLogic', () => {
})
})
it('includes active dashboard when current page context is enabled without insights', async () => {
logic.actions.addOrUpdateActiveInsight(mockInsight, false)
logic.actions.setActiveDashboard(mockDashboard)
logic.actions.enableCurrentPageContext()
it('includes dashboards in compiled context', async () => {
logic.actions.addOrUpdateContextDashboard(mockDashboard)
await expectLogic(logic).toMatchValues({
compiledContext: partial({
@@ -369,39 +276,23 @@ describe('maxContextLogic', () => {
})
describe('listeners', () => {
it('clears active data on location change', async () => {
logic.actions.addOrUpdateActiveInsight(mockInsight, false)
logic.actions.setActiveDashboard(mockDashboard)
it('clears context data on location change', async () => {
logic.actions.addOrUpdateContextInsight(mockInsight as any)
logic.actions.addOrUpdateContextDashboard(mockDashboard)
await expectLogic(logic).toMatchValues({
activeInsights: [expectedTransformedInsight],
activeDashboard: expectedTransformedDashboard,
contextInsights: [expectedTransformedInsight],
contextDashboards: [expectedTransformedDashboard],
})
await expectLogic(logic, () => {
router.actions.push('/new-path')
}).toMatchValues({
activeInsights: [],
activeDashboard: null,
contextInsights: [],
contextDashboards: [],
})
})
it('handles taxonomic filter change for current page context', async () => {
await expectLogic(logic).toMatchValues({
useCurrentPageContext: false,
})
await expectLogic(logic, () => {
logic.actions.handleTaxonomicFilterChange('current_page', TaxonomicFilterGroupType.MaxAIContext, {
id: 'current_page',
name: 'Current page',
value: 'current_page',
icon: IconPageChart,
})
}).toMatchValues({
useCurrentPageContext: true,
})
})
it('handles taxonomic filter change for events', async () => {
await expectLogic(logic).toMatchValues({
contextEvents: [],
@@ -410,7 +301,7 @@ describe('maxContextLogic', () => {
await expectLogic(logic, () => {
logic.actions.handleTaxonomicFilterChange('event-1', TaxonomicFilterGroupType.Events, mockEvent)
}).toMatchValues({
contextEvents: [mockEvent],
contextEvents: [expectedTransformedEvent],
})
})
@@ -422,12 +313,12 @@ describe('maxContextLogic', () => {
await expectLogic(logic, () => {
logic.actions.handleTaxonomicFilterChange(1, TaxonomicFilterGroupType.Actions, mockAction)
}).toMatchValues({
contextActions: [mockAction],
contextActions: [expectedTransformedAction],
})
})
it('preserves context when only panel parameter changes (side panel opening/closing)', async () => {
logic.actions.addOrUpdateContextInsight(mockInsight)
logic.actions.addOrUpdateContextInsight(mockInsight as any)
logic.actions.addOrUpdateContextDashboard(mockDashboard)
await expectLogic(logic).toMatchValues({
@@ -456,4 +347,169 @@ describe('maxContextLogic', () => {
})
})
})
describe('loadAndProcessDashboard', () => {
const mockDashboardLogicInstance = {
mount: jest.fn(),
unmount: jest.fn(),
actions: {
loadDashboard: jest.fn(),
},
values: {
dashboard: mockDashboard,
refreshStatus: {},
},
}
beforeEach(() => {
jest.spyOn(dashboardLogic, 'build').mockReturnValue(mockDashboardLogicInstance as any)
mockDashboardLogicInstance.mount.mockClear()
mockDashboardLogicInstance.unmount.mockClear()
mockDashboardLogicInstance.actions.loadDashboard.mockClear()
})
it('adds preloaded dashboard to context without loading', async () => {
const dashboardData = {
id: 1,
preloaded: mockDashboard,
}
await expectLogic(logic, () => {
logic.actions.loadAndProcessDashboard(dashboardData)
}).toMatchValues({
contextDashboards: [expectedTransformedDashboard],
})
expect(dashboardLogic.build).not.toHaveBeenCalled()
})
it('loads dashboard when not preloaded', async () => {
const dashboardData = {
id: 1,
preloaded: null,
}
// Set the mock values that the function will read
mockDashboardLogicInstance.values.dashboard = mockDashboard
mockDashboardLogicInstance.values.refreshStatus = {}
await expectLogic(logic, () => {
logic.actions.loadAndProcessDashboard(dashboardData)
}).toFinishAllListeners()
expect(dashboardLogic.build).toHaveBeenCalledWith({ id: 1 })
expect(mockDashboardLogicInstance.mount).toHaveBeenCalled()
expect(mockDashboardLogicInstance.actions.loadDashboard).toHaveBeenCalledWith({ action: 'initial_load' })
expect(mockDashboardLogicInstance.unmount).toHaveBeenCalled()
await expectLogic(logic).toMatchValues({
contextDashboards: [expectedTransformedDashboard],
})
})
it('loads dashboard when preloaded dashboard has no tiles', async () => {
const incompleteDashboard = {
...mockDashboard,
tiles: undefined,
}
const dashboardData = {
id: 1,
preloaded: incompleteDashboard as any,
}
// Set the mock values that the function will read
mockDashboardLogicInstance.values.dashboard = mockDashboard
mockDashboardLogicInstance.values.refreshStatus = {}
await expectLogic(logic, () => {
logic.actions.loadAndProcessDashboard(dashboardData)
}).toFinishAllListeners()
expect(dashboardLogic.build).toHaveBeenCalledWith({ id: 1 })
expect(mockDashboardLogicInstance.mount).toHaveBeenCalled()
expect(mockDashboardLogicInstance.actions.loadDashboard).toHaveBeenCalledWith({ action: 'initial_load' })
expect(mockDashboardLogicInstance.unmount).toHaveBeenCalled()
})
})
describe('loadAndProcessInsight', () => {
const mockInsightLogicInstance = {
mount: jest.fn(),
unmount: jest.fn(),
actions: {
loadInsight: jest.fn(),
},
values: {
insight: mockInsight as QueryBasedInsightModel,
},
}
beforeEach(() => {
jest.spyOn(insightLogic, 'build').mockReturnValue(mockInsightLogicInstance as any)
mockInsightLogicInstance.mount.mockClear()
mockInsightLogicInstance.unmount.mockClear()
mockInsightLogicInstance.actions.loadInsight.mockClear()
})
it('adds preloaded insight to context without loading', async () => {
const insightData = {
id: 'insight-1' as InsightShortId,
preloaded: mockInsight as QueryBasedInsightModel,
}
await expectLogic(logic, () => {
logic.actions.loadAndProcessInsight(insightData)
}).toMatchValues({
contextInsights: [expectedTransformedInsight],
})
expect(insightLogic.build).not.toHaveBeenCalled()
})
it('loads insight when not preloaded', async () => {
const insightData = {
id: 'insight-1' as InsightShortId,
preloaded: null,
}
// Set the mock values that the function will read
mockInsightLogicInstance.values.insight = mockInsight as QueryBasedInsightModel
await expectLogic(logic, () => {
logic.actions.loadAndProcessInsight(insightData)
}).toFinishAllListeners()
expect(insightLogic.build).toHaveBeenCalledWith({ dashboardItemId: undefined })
expect(mockInsightLogicInstance.mount).toHaveBeenCalled()
expect(mockInsightLogicInstance.actions.loadInsight).toHaveBeenCalledWith('insight-1')
expect(mockInsightLogicInstance.unmount).toHaveBeenCalled()
await expectLogic(logic).toMatchValues({
contextInsights: [expectedTransformedInsight],
})
})
it('loads insight when preloaded insight has no query', async () => {
const incompleteInsight = {
...mockInsight,
query: null,
}
const insightData = {
id: 'insight-1' as InsightShortId,
preloaded: incompleteInsight as any,
}
// Set the mock values that the function will read
mockInsightLogicInstance.values.insight = mockInsight as QueryBasedInsightModel
await expectLogic(logic, () => {
logic.actions.loadAndProcessInsight(insightData)
}).toFinishAllListeners()
expect(insightLogic.build).toHaveBeenCalledWith({ dashboardItemId: undefined })
expect(mockInsightLogicInstance.mount).toHaveBeenCalled()
expect(mockInsightLogicInstance.actions.loadInsight).toHaveBeenCalledWith('insight-1')
expect(mockInsightLogicInstance.unmount).toHaveBeenCalled()
})
})
})

View File

@@ -1,105 +1,77 @@
import { IconDashboard, IconGraph, IconPageChart } from '@posthog/icons'
import { IconDashboard, IconGraph } from '@posthog/icons'
import { actions, afterMount, connect, kea, listeners, path, reducers, selectors } from 'kea'
import { router } from 'kea-router'
import { TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types'
import { objectsEqual } from 'lib/utils'
import { dashboardLogic, RefreshStatus } from 'scenes/dashboard/dashboardLogic'
import { insightLogic } from 'scenes/insights/insightLogic'
import { insightSceneLogic } from 'scenes/insights/insightSceneLogic'
import { sceneLogic } from 'scenes/sceneLogic'
import { DashboardFilter, HogQLVariable } from '~/queries/schema/schema-general'
import { ActionType, DashboardType, EventDefinition, InsightShortId, QueryBasedInsightModel } from '~/types'
import type { maxContextLogicType } from './maxContextLogicType'
import {
InsightWithQuery,
MaxActionContext,
MaxContextOption,
MaxContextShape,
MaxContextItem,
MaxContextTaxonomicFilterOption,
MaxUIContext,
MaxContextType,
MaxDashboardContext,
MaxEventContext,
MaxInsightContext,
MaxContextInput,
} from './maxTypes'
import { subscriptions } from 'kea-subscriptions'
import { dashboardLogic, RefreshStatus } from 'scenes/dashboard/dashboardLogic'
import {
actionToMaxContextPayload,
dashboardToMaxContext,
eventToMaxContextPayload,
insightToMaxContext,
} from './utils'
// Utility functions for transforming data to max context
const insightToMaxContext = (insight: Partial<QueryBasedInsightModel>): MaxInsightContext => {
const source = (insight.query as any)?.source
return {
id: insight.short_id!,
name: insight.name,
description: insight.description,
query: source,
}
}
// Type definitions for better reusability
export type TaxonomicItem =
| DashboardType
| QueryBasedInsightModel
| EventDefinition
| ActionType
| MaxContextTaxonomicFilterOption
const dashboardToMaxContext = (dashboard: DashboardType<QueryBasedInsightModel>): MaxDashboardContext => {
return {
id: dashboard.id,
name: dashboard.name,
description: dashboard.description,
insights: dashboard.tiles.filter((tile) => tile.insight).map((tile) => insightToMaxContext(tile.insight!)),
filters: dashboard.filters,
}
}
export type DashboardItemInfo = { id: number; preloaded: DashboardType<QueryBasedInsightModel> | null }
export type InsightItemInfo = { id: InsightShortId; preloaded: QueryBasedInsightModel | null }
const eventToMaxContext = (event: EventDefinition): MaxEventContext => {
return {
id: event.id,
name: event.name,
description: event.description,
}
}
const actionToMaxContext = (action: ActionType): MaxActionContext => {
return {
id: action.id,
name: action.name || `Action ${action.id}`,
description: action.description || '',
}
}
type EntityWithIdAndType = { id: string | number; type: string }
// Generic utility functions for reducers
const createAddOrUpdateReducer =
<TContext extends { id: string | number }, TInput>(
transformer: (input: TInput) => TContext,
getId: (input: TInput) => string | number
) =>
(state: TContext[], input: TInput): TContext[] =>
state.filter((item) => item.id !== getId(input)).concat(transformer(input))
const sceneContextReducer = <TContext extends EntityWithIdAndType>(
type: string,
sceneContext: EntityWithIdAndType[]
): TContext[] => sceneContext.filter((item): item is TContext => item.type === type)
const createRemoveReducer =
<TContext extends { id: string | number }>() =>
(state: TContext[], { id }: { id: string | number }): TContext[] =>
state.filter((item) => item.id !== id)
const addOrUpdateEntity = <TContext extends EntityWithIdAndType>(state: TContext[], entity: TContext): TContext[] =>
state.filter((item) => item.id !== entity.id).concat(entity)
const createResetReducer =
<TContext>() =>
(): TContext[] =>
[]
const removeEntity = <TContext extends EntityWithIdAndType>(state: TContext[], id: string | number): TContext[] =>
state.filter((item) => item.id !== id)
// Generic reducer creator
const createEntityReducers = <TContext extends { id: string | number }, TInput>(
transformer: (input: TInput) => TContext,
getId: (input: TInput) => string | number
): {
addOrUpdate: (state: TContext[], input: TInput) => TContext[]
remove: (state: TContext[], { id }: { id: string | number }) => TContext[]
reset: () => TContext[]
} => ({
addOrUpdate: createAddOrUpdateReducer(transformer, getId),
remove: createRemoveReducer<TContext>(),
reset: createResetReducer<TContext>(),
})
export type LoadedEntitiesMap = { dashboard: number[]; insight: string[] }
export const maxContextLogic = kea<maxContextLogicType>([
path(['lib', 'ai', 'maxContextLogic']),
connect(() => ({
values: [insightSceneLogic, ['filtersOverride', 'variablesOverride']],
values: [
insightSceneLogic,
['filtersOverride', 'variablesOverride'],
sceneLogic,
['activeScene', 'activeSceneLogic', 'activeLoadedScene'],
],
actions: [router, ['locationChanged']],
})),
actions({
enableCurrentPageContext: true,
disableCurrentPageContext: true,
addOrUpdateContextInsight: (data: Partial<QueryBasedInsightModel>) => ({ data }),
addOrUpdateContextInsight: (data: InsightWithQuery) => ({ data }),
addOrUpdateContextDashboard: (data: DashboardType<QueryBasedInsightModel>) => ({ data }),
addOrUpdateContextEvent: (data: EventDefinition) => ({ data }),
addOrUpdateContextAction: (data: ActionType) => ({ data }),
@@ -107,101 +79,79 @@ export const maxContextLogic = kea<maxContextLogicType>([
removeContextDashboard: (id: string | number) => ({ id }),
removeContextEvent: (id: string | number) => ({ id }),
removeContextAction: (id: string | number) => ({ id }),
addOrUpdateActiveInsight: (data: Partial<QueryBasedInsightModel>, autoAdd: boolean) => ({
data,
autoAdd,
}),
clearActiveInsights: true,
setActiveDashboard: (data: DashboardType<QueryBasedInsightModel>) => ({ data }),
clearActiveDashboard: true,
loadAndProcessDashboard: (data: DashboardItemInfo) => ({ data }),
loadAndProcessInsight: (data: InsightItemInfo) => ({ data }),
setSelectedContextOption: (value: string) => ({ value }),
handleTaxonomicFilterChange: (
value: string | number,
groupType: TaxonomicFilterGroupType,
item: DashboardType | QueryBasedInsightModel | EventDefinition | ActionType | MaxContextOption
item: TaxonomicItem
) => ({ value, groupType, item }),
resetContext: true,
applyContext: (context: MaxContextItem[]) => ({ context }),
}),
reducers(() => {
const insightReducers = createEntityReducers(insightToMaxContext, (insight) => insight.short_id!)
const dashboardReducers = createEntityReducers(dashboardToMaxContext, (dashboard) => dashboard.id)
const eventReducers = createEntityReducers(eventToMaxContext, (event) => event.id)
const actionReducers = createEntityReducers(actionToMaxContext, (action) => action.id)
return {
useCurrentPageContext: [
false,
{
enableCurrentPageContext: () => true,
disableCurrentPageContext: () => false,
resetContext: () => false,
},
],
contextInsights: [
[] as MaxInsightContext[],
{
addOrUpdateContextInsight: (
state: MaxInsightContext[],
{ data }: { data: Partial<QueryBasedInsightModel> }
) => insightReducers.addOrUpdate(state, data),
removeContextInsight: insightReducers.remove,
resetContext: insightReducers.reset,
addOrUpdateActiveInsight: (
state: MaxInsightContext[],
{ data, autoAdd }: { data: Partial<QueryBasedInsightModel>; autoAdd: boolean }
) => (autoAdd ? insightReducers.addOrUpdate(state, data) : state),
},
],
contextDashboards: [
[] as MaxDashboardContext[],
{
addOrUpdateContextDashboard: (
state: MaxDashboardContext[],
{ data }: { data: DashboardType<QueryBasedInsightModel> }
) => dashboardReducers.addOrUpdate(state, data),
removeContextDashboard: dashboardReducers.remove,
resetContext: dashboardReducers.reset,
},
],
contextEvents: [
[] as MaxEventContext[],
{
addOrUpdateContextEvent: (state: MaxEventContext[], { data }: { data: EventDefinition }) =>
eventReducers.addOrUpdate(state, data),
removeContextEvent: eventReducers.remove,
resetContext: eventReducers.reset,
},
],
contextActions: [
[] as MaxActionContext[],
{
addOrUpdateContextAction: (state: MaxActionContext[], { data }: { data: ActionType }) =>
actionReducers.addOrUpdate(state, data),
removeContextAction: actionReducers.remove,
resetContext: actionReducers.reset,
},
],
activeInsights: [
[] as MaxInsightContext[],
{
addOrUpdateActiveInsight: (
state: MaxInsightContext[],
{ data }: { data: Partial<QueryBasedInsightModel> }
) => insightReducers.addOrUpdate(state, data),
clearActiveInsights: insightReducers.reset,
},
],
activeDashboard: [
null as MaxDashboardContext | null,
{
setActiveDashboard: (_: any, { data }: { data: DashboardType<QueryBasedInsightModel> }) =>
dashboardToMaxContext(data),
clearActiveDashboard: () => null,
},
],
}
reducers({
loadedEntities: [
{ dashboard: [], insight: [] } as LoadedEntitiesMap,
{
loadAndProcessInsight: (state: LoadedEntitiesMap, { data }: { data: InsightItemInfo }) => ({
...state,
insight: [...state.insight, data.id],
}),
loadAndProcessDashboard: (state: LoadedEntitiesMap, { data }: { data: DashboardItemInfo }) => ({
...state,
dashboard: [...state.dashboard, data.id],
}),
},
],
contextInsights: [
[] as MaxInsightContext[],
{
addOrUpdateContextInsight: (state: MaxInsightContext[], { data }: { data: InsightWithQuery }) =>
addOrUpdateEntity(state, insightToMaxContext(data)),
removeContextInsight: (state: MaxInsightContext[], { id }: { id: string | number }) =>
removeEntity(state, id),
applyContext: (_: MaxInsightContext[], { context }: { context: MaxContextItem[] }) =>
sceneContextReducer(MaxContextType.INSIGHT, context),
},
],
contextDashboards: [
[] as MaxDashboardContext[],
{
addOrUpdateContextDashboard: (
state: MaxDashboardContext[],
{ data }: { data: DashboardType<QueryBasedInsightModel> }
) => addOrUpdateEntity(state, dashboardToMaxContext(data)),
removeContextDashboard: (state: MaxDashboardContext[], { id }: { id: string | number }) =>
removeEntity(state, id),
applyContext: (_: MaxDashboardContext[], { context }: { context: MaxContextItem[] }) =>
sceneContextReducer(MaxContextType.DASHBOARD, context),
},
],
contextEvents: [
[] as MaxEventContext[],
{
addOrUpdateContextEvent: (state: MaxEventContext[], { data }: { data: EventDefinition }) =>
addOrUpdateEntity(state, eventToMaxContextPayload(data)),
removeContextEvent: (state: MaxEventContext[], { id }: { id: string | number }) =>
removeEntity(state, id),
applyContext: (_: MaxEventContext[], { context }: { context: MaxContextItem[] }) =>
sceneContextReducer(MaxContextType.EVENT, context),
},
],
contextActions: [
[] as MaxActionContext[],
{
addOrUpdateContextAction: (state: MaxActionContext[], { data }: { data: ActionType }) =>
addOrUpdateEntity(state, actionToMaxContextPayload(data)),
removeContextAction: (state: MaxActionContext[], { id }: { id: string | number }) =>
removeEntity(state, id),
applyContext: (_: MaxActionContext[], { context }: { context: MaxContextItem[] }) =>
sceneContextReducer(MaxContextType.ACTION, context),
},
],
}),
listeners(({ actions, cache }) => ({
listeners(({ actions, cache, values }) => ({
locationChanged: () => {
// Don't reset context if the only change is the side panel opening/closing
const currentLocation = router.values.location
@@ -221,8 +171,6 @@ export const maxContextLogic = kea<maxContextLogicType>([
const shouldResetContext = (): void => {
actions.resetContext()
actions.clearActiveInsights()
actions.clearActiveDashboard()
}
// Always reset context if pathname or search params changed
@@ -251,25 +199,73 @@ export const maxContextLogic = kea<maxContextLogicType>([
shouldResetContext()
}
},
handleTaxonomicFilterChange: async (
{
value,
groupType,
item,
}: {
value: string | number
groupType: TaxonomicFilterGroupType
item: DashboardType | QueryBasedInsightModel | EventDefinition | ActionType | MaxContextOption
},
breakpoint
) => {
try {
// Handle current page context selection
if (groupType === TaxonomicFilterGroupType.MaxAIContext && value === 'current_page') {
actions.enableCurrentPageContext()
return
}
loadAndProcessDashboard: async ({ data }: { data: DashboardItemInfo }, breakpoint) => {
let dashboard = data.preloaded
if (!dashboard || !dashboard.tiles) {
const dashboardLogicInstance = dashboardLogic.build({ id: data.id })
dashboardLogicInstance.mount()
try {
dashboardLogicInstance.actions.loadDashboard({ action: 'initial_load' })
await breakpoint(50)
while (!dashboardLogicInstance.values.dashboard) {
await breakpoint(50)
}
dashboard = dashboardLogicInstance.values.dashboard
// Wait for dashboard items to refresh for cached insights
while (
Object.values(dashboardLogicInstance.values.refreshStatus).some(
(status: RefreshStatus) => status.loading
)
) {
await breakpoint(50)
}
} finally {
dashboardLogicInstance.unmount()
}
}
if (dashboard) {
actions.addOrUpdateContextDashboard(dashboard)
}
},
loadAndProcessInsight: async ({ data }: { data: InsightItemInfo }, breakpoint) => {
let insight = data.preloaded
if (!insight || !insight.query) {
const insightLogicInstance = insightLogic.build({ dashboardItemId: undefined })
insightLogicInstance.mount()
try {
insightLogicInstance.actions.loadInsight(data.id)
await breakpoint(50)
while (!insightLogicInstance.values.insight.query) {
await breakpoint(50)
}
insight = insightLogicInstance.values.insight as QueryBasedInsightModel
} finally {
insightLogicInstance.unmount()
}
}
if (insight) {
actions.addOrUpdateContextInsight(insight)
}
},
handleTaxonomicFilterChange: async ({
groupType,
item,
}: {
groupType: TaxonomicFilterGroupType
item: TaxonomicItem
}) => {
try {
if (groupType === TaxonomicFilterGroupType.Events) {
actions.addOrUpdateContextEvent(item as EventDefinition)
return
@@ -282,19 +278,19 @@ export const maxContextLogic = kea<maxContextLogicType>([
const itemInfo = (() => {
// Handle MaxAI context with string values like "insight_123" or "dashboard_456"
if (groupType === TaxonomicFilterGroupType.MaxAIContext) {
const _item = item as MaxContextOption
if (_item.type === 'insight') {
const _item = item as MaxContextTaxonomicFilterOption
if (_item.type === MaxContextType.INSIGHT) {
return {
type: 'insight',
type: MaxContextType.INSIGHT,
id: _item.value,
preloaded: null,
}
}
if (_item.type === 'dashboard') {
if (_item.type === MaxContextType.DASHBOARD) {
return isNaN(_item.value as number)
? null
: {
type: 'dashboard',
type: MaxContextType.DASHBOARD,
id: _item.value,
preloaded: null,
}
@@ -305,7 +301,7 @@ export const maxContextLogic = kea<maxContextLogicType>([
if (groupType === TaxonomicFilterGroupType.Dashboards) {
const dashboard = item as DashboardType
return {
type: 'dashboard',
type: MaxContextType.DASHBOARD,
id: dashboard.id,
preloaded: dashboard as DashboardType<QueryBasedInsightModel>,
}
@@ -314,7 +310,7 @@ export const maxContextLogic = kea<maxContextLogicType>([
if (groupType === TaxonomicFilterGroupType.Insights) {
const insight = item as QueryBasedInsightModel
return {
type: 'insight',
type: MaxContextType.INSIGHT,
id: insight.short_id,
preloaded: insight,
}
@@ -328,118 +324,115 @@ export const maxContextLogic = kea<maxContextLogicType>([
}
// Handle dashboard selection
if (itemInfo.type === 'dashboard') {
let dashboard = itemInfo.preloaded as DashboardType<QueryBasedInsightModel> | null
if (!dashboard || !dashboard.tiles) {
const dashboardLogicInstance = dashboardLogic.build({ id: itemInfo.id as number })
dashboardLogicInstance.mount()
try {
dashboardLogicInstance.actions.loadDashboard({ action: 'initial_load' })
await breakpoint(50)
while (!dashboardLogicInstance.values.dashboard) {
await breakpoint(50)
}
dashboard = dashboardLogicInstance.values.dashboard
// Wait for dashboard items to refresh for cached insights
while (
Object.values(dashboardLogicInstance.values.refreshStatus).some(
(status: RefreshStatus) => status.loading
)
) {
await breakpoint(50)
}
} finally {
dashboardLogicInstance.unmount()
}
}
actions.addOrUpdateContextDashboard(dashboard)
if (itemInfo.type === MaxContextType.DASHBOARD) {
actions.loadAndProcessDashboard({
id: itemInfo.id as number,
preloaded: itemInfo.preloaded as DashboardType<QueryBasedInsightModel> | null,
})
}
// Handle insight selection
if (itemInfo.type === 'insight') {
let insight = itemInfo.preloaded as QueryBasedInsightModel | null
if (!insight || !insight.query) {
const insightLogicInstance = insightLogic.build({ dashboardItemId: undefined })
insightLogicInstance.mount()
try {
insightLogicInstance.actions.loadInsight(itemInfo.id as InsightShortId)
await breakpoint(50)
while (!insightLogicInstance.values.insight.query) {
await breakpoint(50)
}
insight = insightLogicInstance.values.insight as QueryBasedInsightModel
} finally {
insightLogicInstance.unmount()
}
}
actions.addOrUpdateContextInsight(insight)
if (itemInfo.type === MaxContextType.INSIGHT) {
actions.loadAndProcessInsight({
id: itemInfo.id as InsightShortId,
preloaded: itemInfo.preloaded as QueryBasedInsightModel | null,
})
}
} catch (error) {
console.error('Error handling taxonomic filter change:', error)
}
},
resetContext: () => {
actions.applyContext(values.sceneContext)
},
})),
selectors({
// Automatically collect context from active scene logic
// This selector checks if the current scene logic has a 'maxContext' selector
// and if so, calls it to get context items for MaxAI
rawSceneContext: [
() => [
// Pass scene selector through to get automatic updates when scene changes
(state): MaxContextInput[] => {
const activeSceneLogic = sceneLogic.selectors.activeSceneLogic(state, {})
if (activeSceneLogic && 'maxContext' in activeSceneLogic.selectors) {
try {
const activeLoadedScene = sceneLogic.selectors.activeLoadedScene(state, {})
return activeSceneLogic.selectors.maxContext(
state,
activeLoadedScene?.paramsToProps?.(activeLoadedScene?.sceneParams) || {}
)
} catch {
// If the maxContext selector fails, return empty array
}
}
return []
},
],
(context: MaxContextItem[]): MaxContextItem[] => context,
{ equalityCheck: objectsEqual },
],
sceneContext: [
(s: any) => [s.rawSceneContext],
(rawSceneContext: MaxContextInput[]): MaxContextItem[] => {
return rawSceneContext
.map((item): MaxContextItem | null => {
switch (item.type) {
case MaxContextType.INSIGHT:
return insightToMaxContext(item.data)
case MaxContextType.DASHBOARD:
return dashboardToMaxContext(item.data)
case MaxContextType.EVENT:
return eventToMaxContextPayload(item.data)
case MaxContextType.ACTION:
return actionToMaxContextPayload(item.data)
default:
return null
}
})
.filter((item): item is MaxContextItem => item !== null)
},
],
contextOptions: [
(s: any) => [s.activeInsights, s.activeDashboard, s.contextInsights, s.contextDashboards],
(activeInsights: MaxInsightContext[], activeDashboard: MaxDashboardContext | null): MaxContextOption[] => {
const options: MaxContextOption[] = []
// Add Current page option if there are active items
if (activeInsights.length > 0 || activeDashboard) {
options.push({
id: 'current_page',
name: 'Current page',
value: 'current_page',
icon: IconPageChart,
items: {
insights: activeInsights,
dashboards: activeDashboard ? [activeDashboard] : [],
},
})
}
// Add individual dashboards from context
if (activeDashboard) {
options.push({
id: activeDashboard.id.toString(),
name: activeDashboard.name || `Dashboard ${activeDashboard.id}`,
value: activeDashboard.id,
type: 'dashboard',
icon: IconDashboard,
})
}
// Add individual insights from context
if (activeInsights.length > 0) {
activeInsights.forEach((insight) => {
(s: any) => [s.sceneContext],
(sceneContext: MaxContextItem[]): MaxContextTaxonomicFilterOption[] => {
const options: MaxContextTaxonomicFilterOption[] = []
sceneContext.forEach((item) => {
if (item.type == MaxContextType.INSIGHT) {
options.push({
id: insight.id.toString(),
name: insight.name || `Insight ${insight.id}`,
value: insight.id,
type: 'insight',
id: item.id.toString(),
name: item.name || `Insight ${item.id}`,
value: item.id,
type: MaxContextType.INSIGHT,
icon: IconGraph,
})
})
}
} else if (item.type == MaxContextType.DASHBOARD) {
options.push({
id: item.id.toString(),
name: item.name || `Dashboard ${item.id}`,
value: item.id,
type: MaxContextType.DASHBOARD,
icon: IconDashboard,
})
item.insights.forEach((insight) => {
options.push({
id: insight.id.toString(),
name: insight.name || `Insight ${insight.id}`,
value: insight.id,
type: MaxContextType.INSIGHT,
icon: IconGraph,
})
})
}
})
return options
},
],
mainTaxonomicGroupType: [
(s: any) => [s.contextOptions],
(contextOptions: MaxContextOption[]): TaxonomicFilterGroupType => {
(contextOptions: MaxContextTaxonomicFilterOption[]): TaxonomicFilterGroupType => {
return contextOptions.length > 0
? TaxonomicFilterGroupType.MaxAIContext
: TaxonomicFilterGroupType.Events
@@ -447,7 +440,7 @@ export const maxContextLogic = kea<maxContextLogicType>([
],
taxonomicGroupTypes: [
(s: any) => [s.contextOptions],
(contextOptions: MaxContextOption[]): TaxonomicFilterGroupType[] => {
(contextOptions: MaxContextTaxonomicFilterOption[]): TaxonomicFilterGroupType[] => {
const groupTypes: TaxonomicFilterGroupType[] = []
if (contextOptions.length > 0) {
groupTypes.push(TaxonomicFilterGroupType.MaxAIContext)
@@ -468,9 +461,6 @@ export const maxContextLogic = kea<maxContextLogicType>([
s.contextDashboards,
s.contextEvents,
s.contextActions,
s.useCurrentPageContext,
s.activeInsights,
s.activeDashboard,
s.filtersOverride,
s.variablesOverride,
],
@@ -480,28 +470,19 @@ export const maxContextLogic = kea<maxContextLogicType>([
contextDashboards: MaxDashboardContext[],
contextEvents: MaxEventContext[],
contextActions: MaxActionContext[],
useCurrentPageContext: boolean,
activeInsights: MaxInsightContext[],
activeDashboard: MaxDashboardContext | null,
filtersOverride: DashboardFilter,
variablesOverride: Record<string, HogQLVariable> | null
): MaxContextShape | null => {
const context: MaxContextShape = {}
): MaxUIContext | null => {
const context: MaxUIContext = {}
// Add context dashboards
if (Object.keys(contextDashboards).length > 0) {
context.dashboards = Object.values(contextDashboards)
}
// Add active dashboard if useCurrentPageContext is true
if (useCurrentPageContext && activeDashboard) {
context.dashboards = Object.values(context.dashboards || {}).concat(activeDashboard)
// Add context dashboards (combine manual context + scene context)
if (contextDashboards.length > 0) {
context.dashboards = contextDashboards
}
// Add insights, filtering out those already in dashboards
const allInsights = useCurrentPageContext
? [...(activeInsights || []), ...(contextInsights || [])]
: contextInsights
// Combine manual context, scene context, and active insights
const allInsights = contextInsights
if (allInsights.length > 0) {
// Get all insight IDs from dashboards to filter out duplicates
@@ -545,10 +526,13 @@ export const maxContextLogic = kea<maxContextLogicType>([
context.insights = Array.from(uniqueInsights.values())
}
if (Object.keys(contextEvents).length > 0) {
// Add events
if (contextEvents.length > 0) {
context.events = contextEvents
}
if (Object.keys(contextActions).length > 0) {
// Add actions
if (contextActions.length > 0) {
context.actions = contextActions
}
@@ -556,35 +540,42 @@ export const maxContextLogic = kea<maxContextLogicType>([
},
],
hasData: [
(s: any) => [
s.contextInsights,
s.contextDashboards,
s.contextEvents,
s.contextActions,
s.useCurrentPageContext,
s.activeInsights,
s.activeDashboard,
],
(s: any) => [s.contextInsights, s.contextDashboards, s.contextEvents, s.contextActions],
(
contextInsights: MaxInsightContext[],
contextDashboards: MaxDashboardContext[],
contextEvents: MaxEventContext[],
contextActions: MaxActionContext[],
useCurrentPageContext: boolean,
activeInsights: MaxInsightContext[],
activeDashboard: MaxDashboardContext | null
contextActions: MaxActionContext[]
): boolean => {
return (
contextInsights.length > 0 ||
contextDashboards.length > 0 ||
contextEvents.length > 0 ||
contextActions.length > 0 ||
(useCurrentPageContext && activeInsights && activeInsights.length > 0) ||
(useCurrentPageContext && activeDashboard !== null)
)
return [contextInsights, contextDashboards, contextEvents, contextActions].some((arr) => arr.length > 0)
},
],
}),
subscriptions(({ values, actions }) => ({
rawSceneContext: (rawContext: MaxContextInput[]) => {
rawContext.forEach((item: MaxContextInput) => {
if (
item.type === MaxContextType.INSIGHT &&
item.data.short_id &&
!values.loadedEntities.insight.includes(item.data.short_id)
) {
actions.loadAndProcessInsight({
id: item.data.short_id,
preloaded: item.data as QueryBasedInsightModel,
})
} else if (
item.type === MaxContextType.DASHBOARD &&
item.data.id &&
!values.loadedEntities.dashboard.includes(item.data.id)
) {
actions.loadAndProcessDashboard({ id: item.data.id, preloaded: item.data })
}
})
},
sceneContext: (context: MaxContextItem[]) => {
actions.applyContext(context)
},
})),
afterMount(({ cache }) => {
cache.previousLocation = {
location: router.values.location,

View File

@@ -1,15 +1,19 @@
import { actions, connect, kea, listeners, path, reducers, selectors } from 'kea'
import { OrganizationMembershipLevel } from 'lib/constants'
import { FEATURE_FLAGS, OrganizationMembershipLevel } from 'lib/constants'
import { organizationLogic } from 'scenes/organizationLogic'
import type { maxGlobalLogicType } from './maxGlobalLogicType'
import { sceneLogic } from 'scenes/sceneLogic'
import { urls } from 'scenes/urls'
import { router } from 'kea-router'
import { AssistantContextualTool, AssistantNavigateUrls } from '~/queries/schema/schema-assistant-messages'
import { sceneLogic } from 'scenes/sceneLogic'
import { routes } from 'scenes/scenes'
import { IconCompass } from '@posthog/icons'
import { Scene } from 'scenes/sceneTypes'
import { SidePanelTab } from '~/types'
import { sidePanelLogic } from '~/layout/navigation-3000/sidepanel/sidePanelLogic'
import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
export interface ToolDefinition {
/** A unique identifier for the tool */
@@ -45,7 +49,14 @@ export interface ToolDefinition {
export const maxGlobalLogic = kea<maxGlobalLogicType>([
path(['scenes', 'max', 'maxGlobalLogic']),
connect(() => ({
values: [organizationLogic, ['currentOrganization']],
values: [
organizationLogic,
['currentOrganization'],
sceneLogic,
['scene', 'sceneConfig'],
featureFlagLogic,
['featureFlags'],
],
actions: [router, ['locationChanged']],
})),
actions({
@@ -150,6 +161,24 @@ export const maxGlobalLogic = kea<maxGlobalLogicType>([
},
})),
selectors({
showFloatingMax: [
(s) => [
s.scene,
s.sceneConfig,
s.isFloatingMaxExpanded,
sidePanelLogic.selectors.sidePanelOpen,
sidePanelLogic.selectors.selectedTab,
s.featureFlags,
],
(scene, sceneConfig, isFloatingMaxExpanded, sidePanelOpen, selectedTab, featureFlags) =>
sceneConfig &&
!sceneConfig.onlyUnauthenticated &&
sceneConfig.layout !== 'plain' &&
!(scene === Scene.Max && !isFloatingMaxExpanded) && // In the full Max scene, and Max is not intentionally in floating mode (i.e. expanded)
!(sidePanelOpen && selectedTab === SidePanelTab.Max) && // The Max side panel is open
featureFlags[FEATURE_FLAGS.ARTIFICIAL_HOG] &&
featureFlags[FEATURE_FLAGS.FLOATING_ARTIFICIAL_HOG],
],
dataProcessingAccepted: [
(s) => [s.currentOrganization],
(currentOrganization): boolean => !!currentOrganization?.is_ai_data_processing_approved,

View File

@@ -21,15 +21,6 @@ describe('maxLogic', () => {
logic?.unmount()
})
it("doesn't mount sidePanelStateLogic if it's not already mounted", async () => {
// Mount maxLogic after setting up the sidePanelStateLogic state
logic = maxLogic()
logic.mount()
// Check that sidePanelStateLogic was not mounted
expect(sidePanelStateLogic.isMounted()).toBe(false)
})
it('sets the question when URL has hash param #panel=max:Foo', async () => {
// Set up router with #panel=max:Foo
router.actions.push('', {}, { panel: 'max:Foo' })

View File

@@ -1,37 +1,49 @@
import { DashboardFilter, HogQLVariable, QuerySchema } from '~/queries/schema/schema-general'
import { integer } from '~/queries/schema/type-utils'
import { InsightShortId } from '~/types'
import { ActionType, DashboardType, EventDefinition, InsightShortId, QueryBasedInsightModel } from '~/types'
export enum MaxContextType {
DASHBOARD = 'dashboard',
INSIGHT = 'insight',
EVENT = 'event',
ACTION = 'action',
}
export type InsightWithQuery = Pick<Partial<QueryBasedInsightModel>, 'query'> & Partial<QueryBasedInsightModel>
export interface MaxInsightContext {
type: MaxContextType.INSIGHT
id: InsightShortId
name?: string
description?: string
name?: string | null
description?: string | null
query: QuerySchema // The actual query node, e.g., TrendsQuery, HogQLQuery
}
export interface MaxDashboardContext {
type: MaxContextType.DASHBOARD
id: number
name?: string
description?: string
name?: string | null
description?: string | null
insights: MaxInsightContext[]
filters: DashboardFilter
}
export interface MaxEventContext {
type: MaxContextType.EVENT
id: string
name?: string
description?: string
name?: string | null
description?: string | null
}
export interface MaxActionContext {
type: MaxContextType.ACTION
id: number
name: string
description?: string
description?: string | null
}
// The main shape for the UI context sent to the backend
export interface MaxContextShape {
export interface MaxUIContext {
dashboards?: MaxDashboardContext[]
insights?: MaxInsightContext[]
events?: MaxEventContext[]
@@ -41,14 +53,61 @@ export interface MaxContextShape {
}
// Taxonomic filter options
export interface MaxContextOption {
export interface MaxContextTaxonomicFilterOption {
id: string
value: string | integer
name: string
icon: React.ReactNode
type?: 'dashboard' | 'insight'
items?: {
insights?: MaxInsightContext[]
dashboards?: MaxDashboardContext[]
}
type?: MaxContextType
}
// Union type for all possible context payloads that can be exposed by scene logics
export type MaxContextItem = MaxInsightContext | MaxDashboardContext | MaxEventContext | MaxActionContext
type MaxInsightContextInput = {
type: MaxContextType.INSIGHT
data: InsightWithQuery
}
type MaxDashboardContextInput = {
type: MaxContextType.DASHBOARD
data: DashboardType<QueryBasedInsightModel>
}
type MaxEventContextInput = {
type: MaxContextType.EVENT
data: EventDefinition
}
type MaxActionContextInput = {
type: MaxContextType.ACTION
data: ActionType
}
export type MaxContextInput =
| MaxInsightContextInput
| MaxDashboardContextInput
| MaxEventContextInput
| MaxActionContextInput
/**
* Helper functions to create maxContext items safely
* These ensure proper typing and consistent patterns across scene logics
*/
export const createMaxContextHelpers = {
dashboard: (dashboard: DashboardType<QueryBasedInsightModel>): MaxDashboardContextInput => ({
type: MaxContextType.DASHBOARD,
data: dashboard,
}),
insight: (insight: InsightWithQuery): MaxInsightContextInput => ({
type: MaxContextType.INSIGHT,
data: insight,
}),
event: (event: EventDefinition): MaxEventContextInput => ({
type: MaxContextType.EVENT,
data: event,
}),
action: (action: ActionType): MaxActionContextInput => ({
type: MaxContextType.ACTION,
data: action,
}),
}

View File

@@ -20,7 +20,8 @@ import {
} from '~/queries/schema/schema-assistant-queries'
import { FunnelsQuery, HogQLQuery, RetentionQuery, TrendsQuery } from '~/queries/schema/schema-general'
import { isFunnelsQuery, isHogQLQuery, isRetentionQuery, isTrendsQuery } from '~/queries/utils'
import { SidePanelTab } from '~/types'
import { ActionType, DashboardType, EventDefinition, QueryBasedInsightModel, SidePanelTab } from '~/types'
import { MaxActionContext, MaxContextType, MaxDashboardContext, MaxEventContext, MaxInsightContext } from './maxTypes'
export function isReasoningMessage(message: RootAssistantMessage | undefined | null): message is ReasoningMessage {
return message?.type === AssistantMessageType.Reasoning
@@ -166,3 +167,44 @@ export function generateBurstPoints(spikeCount: number, spikiness: number): stri
return points.trim()
}
// Utility functions for transforming data to max context
export const insightToMaxContext = (insight: Partial<QueryBasedInsightModel>): MaxInsightContext => {
const source = (insight.query as any)?.source
return {
type: MaxContextType.INSIGHT,
id: insight.short_id!,
name: insight.name || insight.derived_name,
description: insight.description,
query: source,
}
}
export const dashboardToMaxContext = (dashboard: DashboardType<QueryBasedInsightModel>): MaxDashboardContext => {
return {
type: MaxContextType.DASHBOARD,
id: dashboard.id,
name: dashboard.name,
description: dashboard.description,
insights: dashboard.tiles.filter((tile) => tile.insight).map((tile) => insightToMaxContext(tile.insight!)),
filters: dashboard.filters,
}
}
export const eventToMaxContextPayload = (event: EventDefinition): MaxEventContext => {
return {
type: MaxContextType.EVENT,
id: event.id,
name: event.name,
description: event.description,
}
}
export const actionToMaxContextPayload = (action: ActionType): MaxActionContext => {
return {
type: MaxContextType.ACTION,
id: action.id,
name: action.name || `Action ${action.id}`,
description: action.description || '',
}
}

View File

@@ -1,12 +1,13 @@
import { connect, kea, path, selectors } from 'kea'
import { loaders } from 'kea-loaders'
import api from 'lib/api'
import { DashboardLogicProps } from 'scenes/dashboard/dashboardLogic'
import { dashboardLogic, DashboardLogicProps } from 'scenes/dashboard/dashboardLogic'
import { MaxContextInput, createMaxContextHelpers } from 'scenes/max/maxTypes'
import { projectLogic } from 'scenes/projectLogic'
import { teamLogic } from 'scenes/teamLogic'
import { getQueryBasedInsightModel } from '~/queries/nodes/InsightViz/utils'
import { DashboardPlacement, InsightModel, QueryBasedInsightModel } from '~/types'
import { DashboardPlacement, DashboardType, InsightModel, QueryBasedInsightModel } from '~/types'
import type { projectHomepageLogicType } from './projectHomepageLogicType'
@@ -28,6 +29,28 @@ export const projectHomepageLogic = kea<projectHomepageLogicType>([
}
: null,
],
maxContext: [
(s) => [
(state) => {
// Get the dashboard from the mounted dashboardLogic
const dashboardLogicProps = s.dashboardLogicProps(state)
if (!dashboardLogicProps) {
return null
}
const logic = dashboardLogic.findMounted(dashboardLogicProps)
if (!logic) {
return null
}
return logic.selectors.dashboard(state)
},
],
(dashboard: DashboardType<QueryBasedInsightModel> | null): MaxContextInput[] => {
if (!dashboard) {
return []
}
return [createMaxContextHelpers.dashboard(dashboard)]
},
],
}),
loaders(({ values }) => ({

View File

@@ -1628,6 +1628,7 @@ class MaxActionContext(BaseModel):
description: Optional[str] = None
id: float
name: str
type: Literal["action"] = "action"
class MaxEventContext(BaseModel):
@@ -1637,6 +1638,7 @@ class MaxEventContext(BaseModel):
description: Optional[str] = None
id: str
name: Optional[str] = None
type: Literal["event"] = "event"
class MinimalHedgehogConfig(BaseModel):
@@ -12309,19 +12311,7 @@ class HumanMessage(BaseModel):
content: str
id: Optional[str] = None
type: Literal["human"] = "human"
ui_context: Optional[MaxContextShape] = None
class MaxContextShape(BaseModel):
model_config = ConfigDict(
extra="forbid",
)
actions: Optional[list[MaxActionContext]] = None
dashboards: Optional[list[MaxDashboardContext]] = None
events: Optional[list[MaxEventContext]] = None
filters_override: Optional[DashboardFilter] = None
insights: Optional[list[MaxInsightContext]] = None
variables_override: Optional[dict[str, HogQLVariable]] = None
ui_context: Optional[MaxUIContext] = None
class MaxDashboardContext(BaseModel):
@@ -12333,6 +12323,7 @@ class MaxDashboardContext(BaseModel):
id: float
insights: list[MaxInsightContext]
name: Optional[str] = None
type: Literal["dashboard"] = "dashboard"
class MaxInsightContext(BaseModel):
@@ -12401,6 +12392,19 @@ class MaxInsightContext(BaseModel):
TracesQuery,
VectorSearchQuery,
] = Field(..., discriminator="kind")
type: Literal["insight"] = "insight"
class MaxUIContext(BaseModel):
model_config = ConfigDict(
extra="forbid",
)
actions: Optional[list[MaxActionContext]] = None
dashboards: Optional[list[MaxDashboardContext]] = None
events: Optional[list[MaxEventContext]] = None
filters_override: Optional[DashboardFilter] = None
insights: Optional[list[MaxInsightContext]] = None
variables_override: Optional[dict[str, HogQLVariable]] = None
class QueryRequest(BaseModel):
@@ -12843,7 +12847,6 @@ class SourceFieldSwitchGroupConfig(BaseModel):
PropertyGroupFilterValue.model_rebuild()
HumanMessage.model_rebuild()
MaxContextShape.model_rebuild()
MaxDashboardContext.model_rebuild()
MaxInsightContext.model_rebuild()
QueryRequest.model_rebuild()