mirror of
https://github.com/oxynote/oxynote.git
synced 2026-10-17 02:49:18 +02:00
add capabilities composable, remove ai assistant feature flag
This commit is contained in:
parent
1391588b63
commit
2dba2e2687
1
docker/env/web.example.env
vendored
1
docker/env/web.example.env
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 ({
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
164
web/app/composables/api/useCapabilitiesAPI.nuxt.test.ts
Normal file
164
web/app/composables/api/useCapabilitiesAPI.nuxt.test.ts
Normal 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("")
|
||||
})
|
||||
})
|
||||
})
|
||||
56
web/app/composables/api/useCapabilitiesAPI.ts
Normal file
56
web/app/composables/api/useCapabilitiesAPI.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
21
web/app/utils/api/capabilities.ts
Normal file
21
web/app/utils/api/capabilities.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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
1
web/index.d.ts
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -139,7 +139,6 @@ export default defineNuxtConfig({
|
|||
coreAPIBaseWsURL: "",
|
||||
termsOfServiceURL: "",
|
||||
privacyPolicyURL: "",
|
||||
experimentalFeatures: "",
|
||||
postgresqlReadOnlyUserSetupGuideURL: "",
|
||||
mysqlReadOnlyUserSetupGuideURL: "",
|
||||
mariadbReadOnlyUserSetupGuideURL: "",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user