add capabilities composable, remove ai assistant feature flag

This commit is contained in:
david 2026-08-26 23:32:04 +03:00
parent 1391588b63
commit 2dba2e2687
21 changed files with 367 additions and 187 deletions

View File

@ -17,7 +17,6 @@ NUXT_PUBLIC_SENTRY_DSN=
NUXT_PUBLIC_LINK_TO_MORE_INFO_ABOUT_PRODUCT=https://oxynote.io
NUXT_PUBLIC_TERMS_OF_SERVICE_URL=
NUXT_PUBLIC_PRIVACY_POLICY_URL=
NUXT_PUBLIC_EXPERIMENTAL_FEATURES=
# in-app documentation links
NUXT_PUBLIC_POSTGRESQL_READ_ONLY_USER_SETUP_GUIDE_URL=https://tableplus.com/blog/2018/04/postgresql-how-to-create-read-only-user.html

View File

@ -13,6 +13,7 @@ import {
mockAuthEndpoint,
mountUnderSidebarProvider,
seedAuthOrganization,
seedCapabilities,
settleMutations,
stubViewportMatches,
t,
@ -356,7 +357,8 @@ describe("<AppSidebar>", { concurrent: false }, () => {
it("hides the github integration on a deployment without it", async ({
expect,
}) => {
stubQueries({ gitHub: { connected: false, configured: false } })
stubQueries({})
seedCapabilities({ github: false })
const wrapper = await mountSidebar()
await settleMutations()
@ -382,7 +384,8 @@ describe("<AppSidebar>", { concurrent: false }, () => {
it("hides the slack integration on a deployment without it", async ({
expect,
}) => {
stubQueries({ slack: { connected: false, configured: false } })
stubQueries({})
seedCapabilities({ slack: false })
const wrapper = await mountSidebar()
await settleMutations()

View File

@ -12,7 +12,7 @@ import {
findButtonByText,
menuItem,
mountUnderSidebarProvider,
seedAuthOrganization,
seedCapabilities,
settleMutations,
t,
} from "./test-helpers"
@ -367,15 +367,28 @@ describe("<DocumentHeader>", { concurrent: false }, () => {
expect(created).toHaveLength(0)
})
it("hides the assistant button while the feature is off", async ({
it("hides the assistant button on a deployment without the assistant", async ({
expect,
}) => {
mainOnly()
seedAuthOrganization({ id: "org-without-the-feature" })
seedCapabilities({ aiAssistant: { status: AssistantStatus.Inactive } })
const wrapper = await mountHeader()
expect(wrapper.text()).not.toContain(t("editor.ai-chat.title"))
expect(wrapper.text()).not.toContain(t("editor.navbar.open-ai-assistant"))
})
it("offers the assistant button when the assistant runs", async ({
expect,
}) => {
mainOnly()
seedCapabilities({
aiAssistant: { status: AssistantStatus.ActiveButWeak, model: "small" },
})
const wrapper = await mountHeader()
expect(wrapper.text()).toContain(t("editor.navbar.open-ai-assistant"))
})
it("publishes the header height so the page can offset content", async ({

View File

@ -1,7 +1,6 @@
<script lang="ts" setup>
import { cn } from "~/lib/utils"
import type { DocumentBreadcrumb } from "./sidebar"
import { ExperimentalFeature } from "~/composables/useExperimentalFeatures"
import { showToastMessage } from "./toast"
const props = defineProps<{
@ -15,10 +14,7 @@ const emit = defineEmits<{
const { isEditable, toggleIsEditable } = useEditorMeta()
const editorStore = useEditorStore()
const { isExperimentalFeatureEnabled } = useExperimentalFeatures()
const aiAssistantEnabled = isExperimentalFeatureEnabled(
ExperimentalFeature.AIAssistant,
)
const { isAssistantEnabled } = useCapabilitiesAPI()
const { useFetchDocumentBranchesByDocId } = useDocumentAPI()
const fetchBranches = useFetchDocumentBranchesByDocId(
() => editorStore.activeDocumentId,
@ -360,7 +356,7 @@ function activateBranch(branch: "default" | "draft") {
</ShadcnUiDropdownMenuContent>
</ShadcnUiDropdownMenu>
<div class="mx-2 h-5.5 w-px bg-border" />
<ShadcnUiTooltip v-if="aiAssistantEnabled" :delay-duration="600">
<ShadcnUiTooltip v-if="isAssistantEnabled" :delay-duration="600">
<ShadcnUiTooltipTrigger as-child>
<ShadcnUiButton
variant="ghost"

View File

@ -14,6 +14,7 @@ import {
} from "~/composables/api/test-helpers"
import {
clearTeleportedOverlays,
seedCapabilities,
t,
WAIT_FOR_OPTIONS,
} from "~/components/test-helpers"
@ -48,6 +49,7 @@ function urlWatcher(overrides: Partial<DocumentHook> = {}) {
}
function mockGitHub(configured: boolean) {
seedCapabilities({ github: configured })
mockEndpoint("GET", "/api/github", () => ({
connected: false,
configured: configured,

View File

@ -8,7 +8,12 @@ import {
seedQueryData,
} from "~/composables/api/test-helpers"
import AppsSection from "./AppsSection.vue"
import { findButtonByText, settleMutations, t } from "../test-helpers"
import {
findButtonByText,
seedCapabilities,
settleMutations,
t,
} from "../test-helpers"
vi.mock("vue-sonner", () => ({
toast: { custom: vi.fn(), dismiss: vi.fn() },
@ -74,7 +79,8 @@ describe("<AppsSection>", { concurrent: false }, () => {
})
it("hides github on a deployment without it", async ({ expect }) => {
seedApps({ gitHub: { connected: false, configured: false } })
seedApps({})
seedCapabilities({ github: false })
const wrapper = await mountSection()
@ -82,7 +88,8 @@ describe("<AppsSection>", { concurrent: false }, () => {
})
it("hides slack on a deployment without it", async ({ expect }) => {
seedApps({ slack: { connected: false, configured: false } })
seedApps({})
seedCapabilities({ slack: false })
const wrapper = await mountSection()
@ -251,7 +258,8 @@ describe("<AppsSection>", { concurrent: false }, () => {
})
it("separates the two rows only when both are shown", async ({ expect }) => {
seedApps({ slack: { connected: false, configured: false } })
seedApps({})
seedCapabilities({ slack: false })
const wrapper = await mountSection()

View File

@ -68,6 +68,20 @@ export function seedAuthAccounts(providerIds: string[]) {
})
}
// every capability defaults to enabled here, matching what the composable
// reports before its request resolves, so each test names only the
// services it is actually gating on
export function seedCapabilities(capabilities: Partial<Capabilities> = {}) {
seedQueryData(["capabilities"], {
github: true,
slack: true,
changeDetection: true,
search: true,
aiAssistant: { status: AssistantStatus.Active, model: "test-model" },
...capabilities,
})
}
// reka-ui tooltips inject a provider context that the app installs once,
// at page level (app/pages/[[organizationSlug]]/[[documentSlug]].vue), so
// a component rendering a tooltip needs one around it to mount at all

View File

@ -0,0 +1,164 @@
import { setResponseStatus } from "h3"
import { afterEach, beforeEach, describe, it } from "vitest"
import {
clearQueryCache,
disposeMockEndpoints,
mockEndpoint,
runInApp,
} from "./test-helpers"
import useCapabilitiesAPI from "./useCapabilitiesAPI"
function makeCapabilitiesAPI() {
return runInApp(() => useCapabilitiesAPI())
}
function stubCapabilities(overrides: Partial<Capabilities> = {}): Capabilities {
return {
github: true,
slack: true,
changeDetection: true,
search: true,
aiAssistant: { status: AssistantStatus.Active, model: "claude-opus-5" },
...overrides,
}
}
// the composable is created once per test and its query loaded eagerly;
// refresh() joins that in-flight load rather than forcing a second request
describe("useCapabilitiesAPI", { concurrent: false }, () => {
// the tests share the app-wide query cache and the test-time endpoint
// registry, so they cannot interleave
beforeEach(clearQueryCache)
afterEach(disposeMockEndpoints)
describe("fetchCapabilities", () => {
it("fetches the deployment's capabilities", async ({ expect }) => {
const capabilities = stubCapabilities()
const calls = mockEndpoint("GET", "/api/capabilities", () => capabilities)
const api = makeCapabilitiesAPI()
const result = await api.fetchCapabilities.refresh()
expect(result.data).toEqual(capabilities)
expect(calls).toHaveLength(1)
})
})
describe("service flags", () => {
it("maps each flag to its own field", async ({ expect }) => {
mockEndpoint("GET", "/api/capabilities", () =>
stubCapabilities({
github: true,
slack: false,
changeDetection: true,
search: false,
}),
)
const api = makeCapabilitiesAPI()
await api.fetchCapabilities.refresh()
expect(api.isGithubEnabled.value).toBe(true)
expect(api.isSlackEnabled.value).toBe(false)
expect(api.isChangeDetectionEnabled.value).toBe(true)
expect(api.isSearchEnabled.value).toBe(false)
})
it("reports every service enabled before the request resolves", ({
expect,
}) => {
const api = makeCapabilitiesAPI()
expect(api.isGithubEnabled.value).toBe(true)
expect(api.isSlackEnabled.value).toBe(true)
expect(api.isChangeDetectionEnabled.value).toBe(true)
expect(api.isSearchEnabled.value).toBe(true)
expect(api.isAssistantEnabled.value).toBe(true)
})
it("reports every service enabled when the request fails", async ({
expect,
}) => {
mockEndpoint("GET", "/api/capabilities", (_call, event) => {
setResponseStatus(event, 500)
return { error: "boom" }
})
const api = makeCapabilitiesAPI()
await api.fetchCapabilities.refresh()
expect(api.fetchCapabilities.error.value).not.toBeNull()
expect(api.isGithubEnabled.value).toBe(true)
expect(api.isSlackEnabled.value).toBe(true)
expect(api.isChangeDetectionEnabled.value).toBe(true)
expect(api.isSearchEnabled.value).toBe(true)
expect(api.isAssistantEnabled.value).toBe(true)
})
})
describe("isAssistantEnabled", () => {
const cc: Record<string, { status: AssistantStatus; enabled: boolean }> = {
"runs a full-strength model": {
status: AssistantStatus.Active,
enabled: true,
},
"runs a mid-tier model with limitations": {
status: AssistantStatus.ActiveButWeak,
enabled: true,
},
"is disabled because the model is too weak": {
status: AssistantStatus.InactiveTooWeak,
enabled: false,
},
"is disabled because no provider is configured": {
status: AssistantStatus.Inactive,
enabled: false,
},
}
Object.entries(cc).forEach(([name, c]) => {
it(`reports ${c.enabled ? "enabled" : "disabled"} when the assistant ${name}`, async ({
expect,
}) => {
mockEndpoint("GET", "/api/capabilities", () =>
stubCapabilities({
aiAssistant: { status: c.status, model: "some-model" },
}),
)
const api = makeCapabilitiesAPI()
await api.fetchCapabilities.refresh()
expect(api.isAssistantEnabled.value).toBe(c.enabled)
})
})
})
describe("assistantStatus / assistantModel", () => {
it("passes the status and configured model through", async ({ expect }) => {
mockEndpoint("GET", "/api/capabilities", () =>
stubCapabilities({
aiAssistant: {
status: AssistantStatus.ActiveButWeak,
model: "gpt-4o-mini",
},
}),
)
const api = makeCapabilitiesAPI()
await api.fetchCapabilities.refresh()
expect(api.assistantStatus.value).toBe(AssistantStatus.ActiveButWeak)
expect(api.assistantModel.value).toBe("gpt-4o-mini")
})
it("reports no status and an empty model while unknown", ({ expect }) => {
const api = makeCapabilitiesAPI()
expect(api.assistantStatus.value).toBeNull()
expect(api.assistantModel.value).toBe("")
})
})
})

View File

@ -0,0 +1,56 @@
const CAPABILITIES_QUERY_KEYS = {
capabilities: ["capabilities"] as const,
}
export default function () {
const { $coreAPIClient } = useNuxtApp()
const fetchCapabilities = useQuery<Capabilities>({
key: CAPABILITIES_QUERY_KEYS.capabilities,
query: async () => {
return await $coreAPIClient<Capabilities>(`/api/capabilities`, {
method: "GET",
})
},
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
staleTime: Infinity,
})
function enabled(pick: (capabilities: Capabilities) => boolean) {
return computed(() => {
const capabilities = fetchCapabilities.data.value
return capabilities ? pick(capabilities) : true
})
}
const isGithubEnabled = enabled((c) => c.github)
const isSlackEnabled = enabled((c) => c.slack)
const isChangeDetectionEnabled = enabled((c) => c.changeDetection)
const isSearchEnabled = enabled((c) => c.search)
const assistantStatus = computed(
() => fetchCapabilities.data.value?.aiAssistant.status ?? null,
)
const assistantModel = computed(
() => fetchCapabilities.data.value?.aiAssistant.model ?? "",
)
const isAssistantEnabled = enabled(
(c) =>
c.aiAssistant.status === AssistantStatus.Active ||
c.aiAssistant.status === AssistantStatus.ActiveButWeak,
)
return {
fetchCapabilities,
isGithubEnabled,
isSlackEnabled,
isChangeDetectionEnabled,
isSearchEnabled,
isAssistantEnabled,
assistantStatus,
assistantModel,
}
}

View File

@ -1,3 +1,4 @@
import { flushPromises } from "@vue/test-utils"
import { afterEach, beforeEach, describe, it } from "vitest"
import {
clearQueryCache,
@ -37,24 +38,40 @@ describe("useGitHubAPI", { concurrent: false }, () => {
expect(result.data).toEqual({ connected: true, configured: true })
expect(statusCalls).toHaveLength(1)
})
// a component only ever reads this query; the explicit refresh()
// below is reserved for the repository queries, which are
// unreachable while GitHub is disabled
it("makes no request on a deployment without the GitHub App", async ({
expect,
}) => {
seedQueryData(["capabilities"], { github: false })
const statusCalls = mockEndpoint("GET", "/api/github", () => ({
connected: true,
configured: true,
}))
const api = makeGitHubAPI()
await flushPromises()
expect(statusCalls).toHaveLength(0)
expect(api.fetchGitHubConnectionStatus.data.value).toBeUndefined()
})
})
describe("gitHubConfigured", () => {
it("reports configured while the status is unknown", ({ expect }) => {
it("reports configured while the capabilities are unknown", ({
expect,
}) => {
const api = makeGitHubAPI()
expect(api.gitHubConfigured.value).toBe(true)
})
it("reports unconfigured once the status says so", async ({ expect }) => {
mockEndpoint("GET", "/api/github", () => ({
connected: false,
configured: false,
}))
it("reports unconfigured once the capabilities say so", ({ expect }) => {
seedQueryData(["capabilities"], { github: false })
const api = makeGitHubAPI()
await api.fetchGitHubConnectionStatus.refresh()
expect(api.gitHubConfigured.value).toBe(false)
})
})

View File

@ -18,6 +18,10 @@ const GITHUB_QUERY_KEYS = {
export default function () {
const { $coreAPIClient } = useNuxtApp()
const queryCache = useQueryCache()
const { isGithubEnabled } = useCapabilitiesAPI()
// whether the server has the GitHub App integration configured at all.
const gitHubConfigured = isGithubEnabled
const fetchGitHubConnectionStatus = useQuery<GitHubConnectionStatus>({
key: GITHUB_QUERY_KEYS.connected,
@ -26,6 +30,7 @@ export default function () {
method: "GET",
})
},
enabled: () => isGithubEnabled.value,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
@ -33,13 +38,6 @@ export default function () {
autoRefetch: true,
})
// whether the server has the GitHub App integration configured at all.
// Treats "unknown" (still loading) as configured so GitHub UI doesn't
// flicker away on deployments that do have it.
const gitHubConfigured = computed(
() => fetchGitHubConnectionStatus.data.value?.configured !== false,
)
async function fetchGitHubInstallURL(): Promise<GitHubInstallResponse> {
// we don't want to use useQuery here as installs are
// typically one-off and we don't want to cache them
@ -110,6 +108,7 @@ export default function () {
},
)
},
enabled: () => isGithubEnabled.value,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
@ -138,7 +137,9 @@ export default function () {
)
},
enabled: () =>
toValue(repoNameRef) !== null && toValue(repoNameRef) !== undefined,
isGithubEnabled.value &&
toValue(repoNameRef) !== null &&
toValue(repoNameRef) !== undefined,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
@ -179,7 +180,9 @@ export default function () {
})
},
enabled: () =>
toValue(repoNameRef) !== null && toValue(repoNameRef) !== undefined,
isGithubEnabled.value &&
toValue(repoNameRef) !== null &&
toValue(repoNameRef) !== undefined,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,

View File

@ -1,3 +1,4 @@
import { flushPromises } from "@vue/test-utils"
import type { FetchError } from "ofetch"
import { afterEach, beforeEach, describe, it } from "vitest"
import {
@ -41,24 +42,37 @@ describe("useSlackAPI", { concurrent: false }, () => {
expect(result.data).toEqual({ connected: true, configured: true })
expect(statusCalls).toHaveLength(1)
})
it("makes no request on a deployment without the Slack App", async ({
expect,
}) => {
seedQueryData(["capabilities"], { slack: false })
const statusCalls = mockEndpoint("GET", "/api/slack", () => ({
connected: true,
configured: true,
}))
const api = makeSlackAPI()
await flushPromises()
expect(statusCalls).toHaveLength(0)
expect(api.fetchSlackConnectionStatus.data.value).toBeUndefined()
})
})
describe("slackConfigured", () => {
it("reports configured while the status is unknown", ({ expect }) => {
it("reports configured while the capabilities are unknown", ({
expect,
}) => {
const api = makeSlackAPI()
expect(api.slackConfigured.value).toBe(true)
})
it("reports unconfigured once the status says so", async ({ expect }) => {
mockEndpoint("GET", "/api/slack", () => ({
connected: false,
configured: false,
}))
it("reports unconfigured once the capabilities say so", ({ expect }) => {
seedQueryData(["capabilities"], { slack: false })
const api = makeSlackAPI()
await api.fetchSlackConnectionStatus.refresh()
expect(api.slackConfigured.value).toBe(false)
})
})

View File

@ -9,6 +9,10 @@ const SLACK_QUERY_KEYS = {
export default function () {
const { $coreAPIClient } = useNuxtApp()
const queryCache = useQueryCache()
const { isSlackEnabled } = useCapabilitiesAPI()
// whether the server has the Slack App integration configured at all.
const slackConfigured = isSlackEnabled
const fetchSlackConnectionStatus = useQuery<SlackConnectionStatus>({
key: SLACK_QUERY_KEYS.connected,
@ -17,6 +21,7 @@ export default function () {
method: "GET",
})
},
enabled: () => isSlackEnabled.value,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
@ -24,13 +29,6 @@ export default function () {
autoRefetch: true,
})
// whether the server has the Slack App integration configured at all.
// Treats "unknown" (still loading) as configured so Slack UI doesn't
// flicker away on deployments that do have it.
const slackConfigured = computed(
() => fetchSlackConnectionStatus.data.value?.configured !== false,
)
async function fetchSlackInstallURL(): Promise<SlackInstallResponse> {
// we don't want to use useQuery here as installs are
// typically one-off and we don't want to cache them
@ -118,6 +116,7 @@ export default function () {
throw error
}
},
enabled: () => isSlackEnabled.value,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,

View File

@ -1,6 +1,7 @@
// Re-exporting allows the content of these files to be auto-imported
// elsewhere.
export * from "./api/useAuthAPI"
export * from "./api/useCapabilitiesAPI"
export * from "./api/useMCPAPI"
export * from "./api/useDocumentAPI"
export * from "./api/useDocumentHookAPI"

View File

@ -1,90 +0,0 @@
import { mockNuxtImport } from "@nuxt/test-utils/runtime"
import { beforeEach, describe, it, vi } from "vitest"
import useExperimentalFeatures, {
ExperimentalFeature,
} from "./useExperimentalFeatures"
const { useAuthSessionMock } = vi.hoisted(() => {
// the default implementation keeps the app bootstrap alive: the
// websocket plugin refreshes the session query during nuxt init
return {
useAuthSessionMock: vi.fn((): any => ({
fetchAuthSession: {
refresh: () => Promise.resolve({ data: undefined }),
},
fetchOrganization: {
refresh: () => Promise.resolve({ data: undefined }),
data: { value: undefined },
},
})),
}
})
mockNuxtImport("useAuthSession", () => useAuthSessionMock)
function arrange(opts: { orgId?: string; features: string }) {
useAuthSessionMock.mockReturnValue({
fetchOrganization: {
data: { value: opts.orgId ? { data: { id: opts.orgId } } : undefined },
},
})
// the runtime config object is mutable at runtime; each test sets the
// value it asserts against
useRuntimeConfig().public.experimentalFeatures = opts.features
}
// the tests arrange a shared module-level mock (mockNuxtImport singleton)
// and the shared runtime config, so they cannot interleave
describe("useExperimentalFeatures", { concurrent: false }, () => {
// restoreMocks does not touch hand-made vi.fn() singletons in vitest 4
// — reset the module-level mock explicitly
beforeEach(() => {
useAuthSessionMock.mockReset()
})
it("enables a feature for a listed organization", ({ expect }) => {
arrange({ orgId: "org2", features: "ai-assistant:org1, org2" })
const { isExperimentalFeatureEnabled } = useExperimentalFeatures()
expect(
isExperimentalFeatureEnabled(ExperimentalFeature.AIAssistant).value,
).toBe(true)
})
it("disables a feature for an unlisted organization", ({ expect }) => {
arrange({ orgId: "org3", features: "ai-assistant:org1,org2" })
const { isExperimentalFeatureEnabled } = useExperimentalFeatures()
expect(
isExperimentalFeatureEnabled(ExperimentalFeature.AIAssistant).value,
).toBe(false)
})
it("disables features without an active organization", ({ expect }) => {
arrange({ features: "ai-assistant:org1" })
const { isExperimentalFeatureEnabled } = useExperimentalFeatures()
expect(
isExperimentalFeatureEnabled(ExperimentalFeature.AIAssistant).value,
).toBe(false)
})
it("disables a feature that is not configured", ({ expect }) => {
arrange({ orgId: "org1", features: "other-feature:org1" })
const { isExperimentalFeatureEnabled } = useExperimentalFeatures()
expect(
isExperimentalFeatureEnabled(ExperimentalFeature.AIAssistant).value,
).toBe(false)
})
it("disables a feature configured without organizations", ({ expect }) => {
arrange({ orgId: "org1", features: "ai-assistant" })
const { isExperimentalFeatureEnabled } = useExperimentalFeatures()
expect(
isExperimentalFeatureEnabled(ExperimentalFeature.AIAssistant).value,
).toBe(false)
})
})

View File

@ -1,40 +0,0 @@
export enum ExperimentalFeature {
AIAssistant = "ai-assistant",
}
export default function () {
const config = useRuntimeConfig()
const { fetchOrganization } = useAuthSession()
function isExperimentalFeatureEnabled(featureName: ExperimentalFeature) {
return computed(() => {
if (import.meta.dev) {
// NOCOV: dev-only shortcut; the vitest nuxt environment is a
// non-dev build. In development, all experimental features are
// enabled for easier testing.
return true
}
const organizationId = fetchOrganization.data.value?.data?.id
if (!organizationId) {
return false
}
const featureConfigs = config.public.experimentalFeatures.split(";")
for (const featureConfig of featureConfigs) {
const [name, orgs] = featureConfig.split(":")
if (name === featureName) {
const orgList = orgs?.split(",").map((org) => org.trim()) ?? []
return orgList.includes(organizationId)
}
}
return false
})
}
return {
isExperimentalFeatureEnabled,
}
}

View File

@ -11,7 +11,6 @@ import {
} from "~/plugins/03.api-fetch"
import { showToastMessage } from "~/components/toast"
import type { Editor } from "@tiptap/core"
import { ExperimentalFeature } from "~/composables/useExperimentalFeatures"
const DOC_CREATION_MIN_SPINNER_TIME_MS = 500
@ -31,6 +30,11 @@ definePageMeta({
return redirectToLogin(to.fullPath, true, nuxtApp)
}
// resolved before the page renders so capability-gated markup is
// already correct server-side and nothing appears then vanishes on
// hydration
await useCapabilitiesAPI().fetchCapabilities.refresh()
if (!to.params.documentSlug || typeof to.params.documentSlug !== "string") {
const docTree = await fetchDocumentTree.refresh()
if (!docTree.data?.length) {
@ -108,7 +112,7 @@ const {
const { fetchOrganization } = useAuthSession()
const { t } = useI18n({ useScope: "global" })
const { setEditable } = useEditorMeta()
const { isExperimentalFeatureEnabled } = useExperimentalFeatures()
const { isAssistantEnabled } = useCapabilitiesAPI()
const wsState = useWebSocketStateStore()
let unsubWsDocMetadataChange: (() => void) | null | undefined = null
let lastClearedLinkNodeHighlight: string | null = null
@ -139,9 +143,6 @@ const pendingDocDeletion = ref<{
name: string
} | null>(null)
const notificationSidebarOpen = ref(false)
const experimentalFeatures = {
aiAssistant: isExperimentalFeatureEnabled(ExperimentalFeature.AIAssistant),
}
const contentEditorRef = shallowRef<Editor | null>(null)
const nameEditorRef = shallowRef<Editor | null>(null)
const editorStore = useEditorStore()
@ -615,7 +616,7 @@ function clearLinkHighlightNodeOnce() {
</div>
</EditorIconPickerProvider>
</main>
<AIChatSidebar v-if="experimentalFeatures.aiAssistant.value" />
<AIChatSidebar v-if="isAssistantEnabled" />
</ShadcnUiTooltipProvider>
</ShadcnUiSidebarProvider>
</template>

View File

@ -0,0 +1,21 @@
// AssistantStatus specifies the assistant's availability, decided by
// core at boot from the strength of the configured model.
export enum AssistantStatus {
Active = "active",
ActiveButWeak = "active-but-weak",
InactiveTooWeak = "inactive-too-weak",
Inactive = "inactive",
}
export interface AssistantCapability {
status: AssistantStatus
model?: string
}
export interface Capabilities {
github: boolean
slack: boolean
aiAssistant: AssistantCapability
changeDetection: boolean
search: boolean
}

View File

@ -1,6 +1,7 @@
// Re-exporting allows the content of these files to be auto-imported
// elsewhere.
export * from "./api/auth"
export * from "./api/capabilities"
export * from "./api/mcp"
export * from "./api/document"
export * from "./api/comment"

1
web/index.d.ts vendored
View File

@ -20,7 +20,6 @@ declare module "nuxt/schema" {
coreAPIBaseWsURL: string
termsOfServiceURL: string
privacyPolicyURL: string
experimentalFeatures: string // example: feature1:org1,org2;feature2:org3
postgresqlReadOnlyUserSetupGuideURL: string
mysqlReadOnlyUserSetupGuideURL: string
mariadbReadOnlyUserSetupGuideURL: string

View File

@ -139,7 +139,6 @@ export default defineNuxtConfig({
coreAPIBaseWsURL: "",
termsOfServiceURL: "",
privacyPolicyURL: "",
experimentalFeatures: "",
postgresqlReadOnlyUserSetupGuideURL: "",
mysqlReadOnlyUserSetupGuideURL: "",
mariadbReadOnlyUserSetupGuideURL: "",