diff --git a/apps/landing/package.json b/apps/landing/package.json index 70b0e7f..fb91937 100644 --- a/apps/landing/package.json +++ b/apps/landing/package.json @@ -17,7 +17,7 @@ "framer-motion": "^12.34.3", "geist": "^1.7.0", "lucide-react": "^0.469.0", - "next": "^15.5.10", + "next": "^15.5.15", "react": "^19.2.3", "react-dom": "^19.2.3", "tailwind-merge": "^2.1.0" diff --git a/apps/web/e2e/global-teardown.ts b/apps/web/e2e/global-teardown.ts index 2cb6bf9..84bf2f7 100644 --- a/apps/web/e2e/global-teardown.ts +++ b/apps/web/e2e/global-teardown.ts @@ -46,7 +46,7 @@ async function globalTeardown() { args: { secret: adminSecret, name: "testing/helpers:cleanupE2ETestData", - mutationArgs: {}, + mutationArgsJson: JSON.stringify({}), }, format: "json", }), diff --git a/apps/web/e2e/helpers/test-data.ts b/apps/web/e2e/helpers/test-data.ts index 78835e9..cf9df43 100644 --- a/apps/web/e2e/helpers/test-data.ts +++ b/apps/web/e2e/helpers/test-data.ts @@ -37,7 +37,11 @@ async function callInternalMutation(path: string, args: Record { const frame = getWidgetContainer(page); + await dismissTour(page); + // Click the rating button (0-10) await frame .locator( diff --git a/apps/web/next.config.js b/apps/web/next.config.js index 5021f99..ca1d5b6 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -46,18 +46,6 @@ const nextConfig = { // Reduce memory usage during webpack compilation webpackMemoryOptimizations: true, }, - webpack: (config, { dev }) => { - if (dev) { - // Use filesystem cache to reduce in-memory pressure during dev - config.cache = { - type: "filesystem", - buildDependencies: { - config: [__filename], - }, - }; - } - return config; - }, async headers() { return [ { diff --git a/apps/web/package.json b/apps/web/package.json index b49f80c..0a2614d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -22,7 +22,7 @@ "fflate": "^0.8.2", "lucide-react": "^0.469.0", "markdown-it": "^14.1.1", - "next": "^15.5.10", + "next": "^15.5.15", "react": "^19.2.3", "react-dom": "^19.2.3" }, diff --git a/apps/web/src/app/inbox/InboxAiReviewPanel.test.tsx b/apps/web/src/app/inbox/InboxAiReviewPanel.test.tsx new file mode 100644 index 0000000..b50d431 --- /dev/null +++ b/apps/web/src/app/inbox/InboxAiReviewPanel.test.tsx @@ -0,0 +1,46 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { Id } from "@opencom/convex/dataModel"; +import { InboxAiReviewPanel } from "./InboxAiReviewPanel"; +import type { InboxAiResponse } from "./inboxRenderTypes"; + +function messageId(value: string): Id<"messages"> { + return value as Id<"messages">; +} + +function responseId(value: string): Id<"aiResponses"> { + return value as Id<"aiResponses">; +} + +describe("InboxAiReviewPanel", () => { + it("renders persisted model and provider metadata for AI responses", () => { + const response: InboxAiResponse = { + _id: responseId("response_1"), + createdAt: Date.now(), + query: "How do I reset my password?", + response: "Go to Settings > Security > Reset Password.", + confidence: 0.82, + model: "openai/gpt-5-nano", + provider: "openai", + handedOff: false, + messageId: messageId("message_1"), + sources: [], + deliveredResponseContext: null, + generatedResponseContext: null, + }; + + render( + reason ?? "No reason"} + /> + ); + + expect(screen.getByText("Model openai/gpt-5-nano")).toBeInTheDocument(); + expect(screen.getByText("Provider openai")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/app/inbox/InboxAiReviewPanel.tsx b/apps/web/src/app/inbox/InboxAiReviewPanel.tsx index 14cfcda..91b5b74 100644 --- a/apps/web/src/app/inbox/InboxAiReviewPanel.tsx +++ b/apps/web/src/app/inbox/InboxAiReviewPanel.tsx @@ -136,6 +136,12 @@ export function InboxAiReviewPanel({ {confidenceLabel} {Math.round(confidenceValue * 100)}% + + Model {response.model} + + + Provider {response.provider} + {response.feedback && ( Feedback {response.feedback === "helpful" ? "helpful" : "not helpful"} diff --git a/apps/web/src/app/inbox/inboxRenderTypes.ts b/apps/web/src/app/inbox/inboxRenderTypes.ts index f3809de..1bee376 100644 --- a/apps/web/src/app/inbox/inboxRenderTypes.ts +++ b/apps/web/src/app/inbox/inboxRenderTypes.ts @@ -87,6 +87,8 @@ export interface InboxAiResponse { query: string; response: string; confidence: number; + model: string; + provider: string; handedOff: boolean; handoffReason?: string | null; messageId: Id<"messages">; diff --git a/apps/web/src/app/settings/AIAgentSection.test.tsx b/apps/web/src/app/settings/AIAgentSection.test.tsx new file mode 100644 index 0000000..1ed0fc8 --- /dev/null +++ b/apps/web/src/app/settings/AIAgentSection.test.tsx @@ -0,0 +1,87 @@ +import { act, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Id } from "@opencom/convex/dataModel"; +import { AIAgentSection } from "./AIAgentSection"; +import { useWebAction, useWebMutation, useWebQuery } from "@/lib/convex/hooks"; + +vi.mock("@/lib/convex/hooks", () => ({ + useWebAction: vi.fn(), + useWebMutation: vi.fn(), + useWebQuery: vi.fn(), + webActionRef: vi.fn((functionName: string) => functionName), + webMutationRef: vi.fn((functionName: string) => functionName), + webQueryRef: vi.fn((functionName: string) => functionName), +})); + +describe("AIAgentSection model discovery fallbacks", () => { + const workspaceId = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" as unknown as Id<"workspaces">; + const aiSettingsFixture = { + enabled: true, + model: "openai/gpt-5-nano", + confidenceThreshold: 0.6, + knowledgeSources: ["articles"], + personality: "", + handoffMessage: "", + suggestionsEnabled: false, + embeddingModel: "text-embedding-3-small", + lastConfigError: null, + } as const; + + let listAvailableModelsMock: ReturnType; + let rejectDiscovery: ((reason?: unknown) => void) | undefined; + + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "error").mockImplementation(() => {}); + + const mockedUseWebQuery = useWebQuery as unknown as ReturnType; + mockedUseWebQuery.mockImplementation((_: unknown, args: unknown) => { + if (args === "skip") { + return undefined; + } + + return aiSettingsFixture; + }); + + listAvailableModelsMock = vi.fn( + () => + new Promise((_, reject) => { + rejectDiscovery = reject; + }) + ); + + const mockedUseWebAction = useWebAction as unknown as ReturnType; + mockedUseWebAction.mockReturnValue(listAvailableModelsMock); + + const mockedUseWebMutation = useWebMutation as unknown as ReturnType; + mockedUseWebMutation.mockReturnValue(vi.fn().mockResolvedValue(undefined)); + }); + + it("stops showing the loading placeholder when model discovery fails", async () => { + render(); + + await waitFor(() => { + expect(listAvailableModelsMock).toHaveBeenCalledWith({ + workspaceId, + selectedModel: aiSettingsFixture.model, + }); + }); + + expect(screen.getByRole("option", { name: /loading discovered models/i })).toBeInTheDocument(); + + await act(async () => { + rejectDiscovery?.(new Error("Discovery failed")); + }); + + await waitFor(() => { + expect(screen.getByRole("option", { name: /model discovery unavailable/i })).toBeInTheDocument(); + }); + + expect( + screen.getByText(/model discovery is currently unavailable\. enter a model id manually/i) + ).toBeInTheDocument(); + expect( + screen.queryByRole("option", { name: /loading discovered models/i }) + ).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/app/settings/AIAgentSection.tsx b/apps/web/src/app/settings/AIAgentSection.tsx index 738d87b..96078da 100644 --- a/apps/web/src/app/settings/AIAgentSection.tsx +++ b/apps/web/src/app/settings/AIAgentSection.tsx @@ -6,12 +6,17 @@ import { AlertTriangle, Bot } from "lucide-react"; import type { Id } from "@opencom/convex/dataModel"; import { useAIAgentSectionConvex } from "./hooks/useSettingsSectionsConvex"; +function normalizeModelValue(value: string): string { + return value.trim(); +} + export function AIAgentSection({ workspaceId, }: { workspaceId?: Id<"workspaces">; }): React.JSX.Element | null { - const { aiSettings, availableModels, updateSettings } = useAIAgentSectionConvex(workspaceId); + const { aiSettings, availableModels, availableModelsStatus, isSaving, saveSettings } = + useAIAgentSectionConvex(workspaceId); const [enabled, setEnabled] = useState(false); const [model, setModel] = useState("openai/gpt-5-nano"); @@ -21,7 +26,17 @@ export function AIAgentSection({ const [handoffMessage, setHandoffMessage] = useState(""); const [suggestionsEnabled, setSuggestionsEnabled] = useState(false); const [embeddingModel, setEmbeddingModel] = useState("text-embedding-3-small"); - const [isSaving, setIsSaving] = useState(false); + const normalizedModel = normalizeModelValue(model); + const selectedDiscoveredModel = + availableModels?.some((availableModel) => availableModel.id === normalizedModel) ?? false + ? normalizedModel + : ""; + const discoveredModelsPlaceholder = + availableModelsStatus === "loading" + ? "Loading discovered models..." + : availableModelsStatus === "error" + ? "Model discovery unavailable" + : "Choose a discovered model"; useEffect(() => { if (aiSettings) { @@ -38,23 +53,19 @@ export function AIAgentSection({ const handleSave = async () => { if (!workspaceId) return; - setIsSaving(true); - try { - await updateSettings({ - workspaceId, - enabled, - model, - confidenceThreshold, - knowledgeSources: knowledgeSources as ("articles" | "internalArticles" | "snippets")[], - personality: personality || undefined, - handoffMessage: handoffMessage || undefined, - suggestionsEnabled, - embeddingModel, - }); - } catch (error) { - console.error("Failed to save AI settings:", error); - } finally { - setIsSaving(false); + const nextModel = await saveSettings({ + workspaceId, + enabled, + model, + confidenceThreshold, + knowledgeSources: knowledgeSources as ("articles" | "internalArticles" | "snippets")[], + personality, + handoffMessage, + suggestionsEnabled, + embeddingModel, + }); + if (nextModel) { + setModel(nextModel); } }; @@ -131,18 +142,31 @@ export function AIAgentSection({
+ setModel(e.target.value)} + placeholder="openai/gpt-5-nano" + /> + {availableModelsStatus === "error" && ( +

+ Model discovery is currently unavailable. Enter a model ID manually or try again + later. +

+ )}

- Choose the AI model for generating responses. + Choose a discovered model or enter one manually. Raw model IDs are interpreted + against the currently configured AI gateway runtime.

@@ -252,9 +276,6 @@ export function AIAgentSection({ -

diff --git a/apps/web/src/app/settings/hooks/useSettingsSectionsConvex.ts b/apps/web/src/app/settings/hooks/useSettingsSectionsConvex.ts index c1711aa..f30dc63 100644 --- a/apps/web/src/app/settings/hooks/useSettingsSectionsConvex.ts +++ b/apps/web/src/app/settings/hooks/useSettingsSectionsConvex.ts @@ -1,5 +1,6 @@ "use client"; +import { useCallback, useEffect, useState } from "react"; import type { Id } from "@opencom/convex/dataModel"; import type { HomeCard, HomeConfig, HomeDefaultSpace, HomeTab } from "@opencom/types"; import { @@ -15,6 +16,57 @@ type WorkspaceArgs = { workspaceId: Id<"workspaces">; }; +type AIAgentKnowledgeSource = "articles" | "internalArticles" | "snippets"; + +type AIAgentSettingsQueryResult = { + enabled: boolean; + model: string; + confidenceThreshold: number; + knowledgeSources: AIAgentKnowledgeSource[]; + personality?: string; + handoffMessage?: string; + suggestionsEnabled?: boolean; + embeddingModel?: string; + lastConfigError?: { + message: string; + code: string; + provider?: string; + model?: string; + } | null; +} | null; + +type AvailableAIAgentModel = { + id: string; + name: string; + provider: string; +}; + +type AvailableModelsStatus = "idle" | "loading" | "loaded" | "error"; + +type UpdateAIAgentSettingsArgs = { + workspaceId: Id<"workspaces">; + enabled?: boolean; + model?: string; + confidenceThreshold?: number; + knowledgeSources?: AIAgentKnowledgeSource[]; + personality?: string; + handoffMessage?: string; + suggestionsEnabled?: boolean; + embeddingModel?: string; +}; + +type SaveAIAgentSettingsArgs = { + workspaceId: Id<"workspaces">; + enabled: boolean; + model: string; + confidenceThreshold: number; + knowledgeSources: AIAgentKnowledgeSource[]; + personality: string; + handoffMessage: string; + suggestionsEnabled: boolean; + embeddingModel: string; +}; + type AuditAccessRecord = { status: "unauthenticated" | "forbidden" | "ok"; canManageSecurity?: boolean; @@ -64,43 +116,16 @@ type SuccessResponse = { success: boolean; }; -const AI_SETTINGS_QUERY_REF = webQueryRef< - WorkspaceArgs, - { - enabled: boolean; - model: string; - confidenceThreshold: number; - knowledgeSources: string[]; - personality?: string; - handoffMessage?: string; - suggestionsEnabled?: boolean; - embeddingModel?: string; - lastConfigError?: { - message: string; - code: string; - provider?: string; - model?: string; - } | null; - } | null ->("aiAgent:getSettings"); -const AVAILABLE_MODELS_QUERY_REF = webQueryRef< - Record, - Array<{ id: string; name: string; provider: string }> +const AI_SETTINGS_QUERY_REF = webQueryRef( + "aiAgent:getSettings" +); +const AVAILABLE_MODELS_ACTION_REF = webActionRef< + { workspaceId: Id<"workspaces">; selectedModel?: string }, + AvailableAIAgentModel[] >("aiAgent:listAvailableModels"); -const UPDATE_AI_SETTINGS_REF = webMutationRef< - { - workspaceId: Id<"workspaces">; - enabled?: boolean; - model?: string; - confidenceThreshold?: number; - knowledgeSources?: Array<"articles" | "internalArticles" | "snippets">; - personality?: string; - handoffMessage?: string; - suggestionsEnabled?: boolean; - embeddingModel?: string; - }, - null ->("aiAgent:updateSettings"); +const UPDATE_AI_SETTINGS_REF = webMutationRef>( + "aiAgent:updateSettings" +); const AUTOMATION_SETTINGS_QUERY_REF = webQueryRef< WorkspaceArgs, { @@ -278,10 +303,86 @@ const TRANSFER_OWNERSHIP_REF = webMutationRef) { + const aiSettings = useWebQuery(AI_SETTINGS_QUERY_REF, workspaceId ? { workspaceId } : "skip"); + const listAvailableModels = useWebAction(AVAILABLE_MODELS_ACTION_REF); + const updateAIAgentSettings = useWebMutation(UPDATE_AI_SETTINGS_REF); + const [availableModels, setAvailableModels] = useState( + undefined + ); + const [availableModelsStatus, setAvailableModelsStatus] = + useState("idle"); + const [isSaving, setIsSaving] = useState(false); + + const saveSettings = useCallback( + async (settings: SaveAIAgentSettingsArgs): Promise => { + const normalizedModel = settings.model.trim(); + setIsSaving(true); + try { + await updateAIAgentSettings({ + workspaceId: settings.workspaceId, + enabled: settings.enabled, + model: normalizedModel, + confidenceThreshold: settings.confidenceThreshold, + knowledgeSources: settings.knowledgeSources, + personality: settings.personality || undefined, + handoffMessage: settings.handoffMessage || undefined, + suggestionsEnabled: settings.suggestionsEnabled, + embeddingModel: settings.embeddingModel, + }); + return normalizedModel; + } catch (error) { + console.error("Failed to save AI settings:", error); + return null; + } finally { + setIsSaving(false); + } + }, + [updateAIAgentSettings] + ); + + useEffect(() => { + let cancelled = false; + + if (!workspaceId) { + setAvailableModels(undefined); + setAvailableModelsStatus("idle"); + return () => { + cancelled = true; + }; + } + + setAvailableModels(undefined); + setAvailableModelsStatus("loading"); + + void listAvailableModels({ + workspaceId, + selectedModel: aiSettings?.model, + }) + .then((models) => { + if (!cancelled) { + setAvailableModels(models); + setAvailableModelsStatus("loaded"); + } + }) + .catch((error) => { + console.error("Failed to load available AI models:", error); + if (!cancelled) { + setAvailableModels([]); + setAvailableModelsStatus("error"); + } + }); + + return () => { + cancelled = true; + }; + }, [workspaceId, aiSettings?.model, listAvailableModels]); + return { - aiSettings: useWebQuery(AI_SETTINGS_QUERY_REF, workspaceId ? { workspaceId } : "skip"), - availableModels: useWebQuery(AVAILABLE_MODELS_QUERY_REF, {}), - updateSettings: useWebMutation(UPDATE_AI_SETTINGS_REF), + aiSettings, + availableModels, + availableModelsStatus, + isSaving, + saveSettings, }; } diff --git a/apps/web/src/components/SuggestionsPanel.test.tsx b/apps/web/src/components/SuggestionsPanel.test.tsx new file mode 100644 index 0000000..0b15d04 --- /dev/null +++ b/apps/web/src/components/SuggestionsPanel.test.tsx @@ -0,0 +1,95 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Id } from "@opencom/convex/dataModel"; +import { SuggestionsPanel } from "./SuggestionsPanel"; + +const mocks = vi.hoisted(() => ({ + getSuggestions: vi.fn(), + trackUsage: vi.fn(), + trackDismissal: vi.fn(), +})); + +vi.mock("@/components/hooks/useSuggestionsPanelConvex", () => ({ + useSuggestionsPanelConvex: () => ({ + settings: { + suggestionsEnabled: true, + embeddingModel: "text-embedding-3-small", + }, + getSuggestions: mocks.getSuggestions, + trackUsage: mocks.trackUsage, + trackDismissal: mocks.trackDismissal, + }), +})); + +function workspaceId(value: string): Id<"workspaces"> { + return value as Id<"workspaces">; +} + +function conversationId(value: string): Id<"conversations"> { + return value as Id<"conversations">; +} + +describe("SuggestionsPanel", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getSuggestions.mockResolvedValue([ + { + id: "article_1", + type: "article", + title: "Reset Password", + snippet: "Go to Settings > Security.", + content: "Full reset password content", + score: 0.91, + embeddingModel: "text-embedding-3-small", + }, + ]); + }); + + it("shows the resolved embedding model and passes it into usage tracking", async () => { + render( + + ); + + expect(await screen.findByText("Using embedding model: text-embedding-3-small")).toBeInTheDocument(); + expect(screen.getByText("Embedding model text-embedding-3-small")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /insert/i })); + + await waitFor(() => { + expect(mocks.trackUsage).toHaveBeenCalledWith({ + workspaceId: workspaceId("workspace_1"), + conversationId: conversationId("conversation_1"), + contentType: "article", + contentId: "article_1", + embeddingModel: "text-embedding-3-small", + }); + }); + }); + + it("passes the embedding model into dismissal tracking", async () => { + render( + + ); + + await screen.findByText("Reset Password"); + fireEvent.click(screen.getByRole("button", { name: /dismiss/i })); + + await waitFor(() => { + expect(mocks.trackDismissal).toHaveBeenCalledWith({ + workspaceId: workspaceId("workspace_2"), + conversationId: conversationId("conversation_2"), + contentType: "article", + contentId: "article_1", + embeddingModel: "text-embedding-3-small", + }); + }); + }); +}); diff --git a/apps/web/src/components/SuggestionsPanel.tsx b/apps/web/src/components/SuggestionsPanel.tsx index b12c7f8..720e5e6 100644 --- a/apps/web/src/components/SuggestionsPanel.tsx +++ b/apps/web/src/components/SuggestionsPanel.tsx @@ -37,6 +37,8 @@ export function SuggestionsPanel({ const [dismissedIds, setDismissedIds] = useState>(new Set()); const { settings, getSuggestions, trackUsage, trackDismissal } = useSuggestionsPanelConvex(workspaceId); + const resolvedEmbeddingModel = + suggestions[0]?.embeddingModel ?? settings?.embeddingModel ?? "text-embedding-3-small"; const fetchSuggestions = useCallback(async () => { if (!settings?.suggestionsEnabled) { @@ -82,6 +84,7 @@ export function SuggestionsPanel({ conversationId, contentType: suggestion.type, contentId: suggestion.id, + embeddingModel: suggestion.embeddingModel, }); } catch (err) { console.error("Failed to track usage:", err); @@ -97,6 +100,7 @@ export function SuggestionsPanel({ conversationId, contentType: suggestion.type, contentId: suggestion.id, + embeddingModel: suggestion.embeddingModel, }); } catch (err) { console.error("Failed to track dismissal:", err); @@ -166,6 +170,10 @@ export function SuggestionsPanel({ +

+ Using embedding model: {resolvedEmbeddingModel} +

+ {isLoading && visibleSuggestions.length === 0 && (
@@ -198,6 +206,11 @@ export function SuggestionsPanel({

{suggestion.snippet}

+ {suggestion.embeddingModel && ( +

+ Embedding model {suggestion.embeddingModel} +

+ )}