diff --git a/AGENTS.md b/AGENTS.md index d51897c..738062a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,6 +5,7 @@ - Use **PNPM** commands in this repo (workspace uses `pnpm-workspace.yaml`). - Always run new/updated tests after creating or changing them. - Prefer focused verification first (targeted package/spec), then broader checks when needed. +- At the end of each proposal when ready for a PR, run `pnpm ci:check` to ensure all checks pass. ## Quick Repo Orientation @@ -648,3 +649,11 @@ If you are about to: - create a ref inside a React component stop and use one of the standard patterns above instead. + + +This project uses [Convex](https://convex.dev) as its backend. + +When working on Convex code, **always read `convex/_generated/ai/guidelines.md` first** for important guidelines on how to correctly use Convex APIs and patterns. The file contains rules that override what you may have learned about Convex from training data. + +Convex agent skills for common tasks can be installed by running `npx convex ai-files install`. + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ba21163 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,7 @@ + +This project uses [Convex](https://convex.dev) as its backend. + +When working on Convex code, **always read `convex/_generated/ai/guidelines.md` first** for important guidelines on how to correctly use Convex APIs and patterns. The file contains rules that override what you may have learned about Convex from training data. + +Convex agent skills for common tasks can be installed by running `npx convex ai-files install`. + diff --git a/ROADMAP.md b/ROADMAP.md index 5090218..29dbe63 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -50,7 +50,9 @@ Goal: ship a professional open-source customer messaging platform with strong de - [p] a CI AI agent to check for any doc drift and update docs based on the latest code - [ ] convert supportAttachments.finalizeUpload into an action + internal mutation pipeline so we can add real signature checks too. The current finalizeUpload boundary is a Convex mutation and ctx.storage.get() is only available in actions. Doing true magic-byte validation would need a larger refactor of that finalize flow. - [ ] add URL param deep links for the widget - Go to a url like ?open-widget-tab=home to open the widget to that tab, etc. - +- [ ] make web admin chat input field multi line, with scrollbar when needed (currently single line max) +- [ ] make clicking anywhere on the settings headers expand that section, not just the show/hide button +- [ ] add full evals, traces, etc. to the AI agent apps/web/src/app/outbound/[id]/OutboundTriggerPanel.tsx Comment on lines +67 to 71 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/mobile/package.json b/apps/mobile/package.json index c32a756..b68d066 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -18,7 +18,7 @@ "@opencom/convex": "workspace:*", "@opencom/types": "workspace:*", "@react-native-async-storage/async-storage": "^2.1.2", - "convex": "^1.32.0", + "convex": "1.35.1", "expo": "~54.0.33", "expo-clipboard": "^8.0.8", "expo-constants": "~18.0.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 59e7c4f..0074cc0 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -17,12 +17,12 @@ "@opencom/types": "workspace:*", "@opencom/ui": "workspace:*", "@opencom/web-shared": "workspace:*", - "convex": "^1.32.0", + "convex": "1.35.1", "dompurify": "^3.3.1", "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/articles/hooks/useArticlesAdminConvex.ts b/apps/web/src/app/articles/hooks/useArticlesAdminConvex.ts index 6b0bd0c..63d403f 100644 --- a/apps/web/src/app/articles/hooks/useArticlesAdminConvex.ts +++ b/apps/web/src/app/articles/hooks/useArticlesAdminConvex.ts @@ -2,8 +2,10 @@ import type { Id } from "@opencom/convex/dataModel"; import { + useWebAction, useWebMutation, useWebQuery, + webActionRef, webMutationRef, webQueryRef, } from "@/lib/convex/hooks"; @@ -79,6 +81,18 @@ type LogExportArgs = WorkspaceArgs & { recordCount: number; }; +type BackfillEmbeddingsArgs = WorkspaceArgs & { + contentTypes?: ("article" | "internalArticle" | "snippet")[]; + batchSize?: number; + model?: string; +}; + +type BackfillEmbeddingsResult = { + total: number; + processed: number; + skipped: number; +}; + const ARTICLES_LIST_QUERY_REF = webQueryRef("articles:list"); const COLLECTIONS_LIST_HIERARCHY_QUERY_REF = webQueryRef( "collections:listHierarchy" @@ -107,6 +121,9 @@ const GENERATE_ASSET_UPLOAD_URL_REF = webMutationRef("auditLogs:logExport"); +const BACKFILL_EMBEDDINGS_REF = webActionRef( + "embeddings:backfillExisting" +); type UseArticlesAdminConvexOptions = { workspaceId?: Id<"workspaces"> | null; @@ -120,10 +137,7 @@ export function useArticlesAdminConvex({ exportSourceId, }: UseArticlesAdminConvexOptions) { return { - articles: useWebQuery( - ARTICLES_LIST_QUERY_REF, - workspaceId ? { workspaceId } : "skip" - ), + articles: useWebQuery(ARTICLES_LIST_QUERY_REF, workspaceId ? { workspaceId } : "skip"), collections: useWebQuery( COLLECTIONS_LIST_HIERARCHY_QUERY_REF, workspaceId ? { workspaceId } : "skip" @@ -135,10 +149,7 @@ export function useArticlesAdminConvex({ IMPORT_HISTORY_QUERY_REF, workspaceId ? { workspaceId, limit: 10 } : "skip" ), - importSources: useWebQuery( - IMPORT_SOURCES_QUERY_REF, - workspaceId ? { workspaceId } : "skip" - ), + importSources: useWebQuery(IMPORT_SOURCES_QUERY_REF, workspaceId ? { workspaceId } : "skip"), logExport: useWebMutation(LOG_EXPORT_REF), markdownExport: useWebQuery( EXPORT_MARKDOWN_QUERY_REF, @@ -154,5 +165,6 @@ export function useArticlesAdminConvex({ restoreImportRun: useWebMutation(RESTORE_IMPORT_RUN_REF), syncMarkdownFolder: useWebMutation(SYNC_MARKDOWN_FOLDER_REF), unpublishArticle: useWebMutation(UNPUBLISH_ARTICLE_REF), + backfillEmbeddings: useWebAction(BACKFILL_EMBEDDINGS_REF), }; } diff --git a/apps/web/src/app/articles/page.tsx b/apps/web/src/app/articles/page.tsx index c2a3134..5a762b2 100644 --- a/apps/web/src/app/articles/page.tsx +++ b/apps/web/src/app/articles/page.tsx @@ -5,7 +5,7 @@ import { useRouter, useSearchParams } from "next/navigation"; import { useAuth } from "@/contexts/AuthContext"; import { AppLayout } from "@/components/AppLayout"; import { Button } from "@opencom/ui"; -import { Plus } from "lucide-react"; +import { Plus, RefreshCw } from "lucide-react"; import Link from "next/link"; import type { Id } from "@opencom/convex/dataModel"; import { strToU8, zipSync } from "fflate"; @@ -39,12 +39,8 @@ function ArticlesContent() { const searchParams = useSearchParams(); const { activeWorkspace } = useAuth(); const [searchQuery, setSearchQuery] = useState(""); - const [collectionFilter, setCollectionFilter] = useState( - ALL_COLLECTION_FILTER - ); - const [visibilityFilter, setVisibilityFilter] = useState( - ALL_VISIBILITY_FILTER - ); + const [collectionFilter, setCollectionFilter] = useState(ALL_COLLECTION_FILTER); + const [visibilityFilter, setVisibilityFilter] = useState(ALL_VISIBILITY_FILTER); const [statusFilter, setStatusFilter] = useState(ALL_STATUS_FILTER); const [importSourceName, setImportSourceName] = useState(""); const [importTargetCollectionId, setImportTargetCollectionId] = useState< @@ -68,6 +64,9 @@ function ArticlesContent() { const [deleteTarget, setDeleteTarget] = useState(null); const [deleteError, setDeleteError] = useState(null); const [isDeletingArticle, setIsDeletingArticle] = useState(false); + const [isBackfillingEmbeddings, setIsBackfillingEmbeddings] = useState(false); + const [backfillNotice, setBackfillNotice] = useState(null); + const [backfillError, setBackfillError] = useState(null); const folderInputRef = useRef(null); const createQueryHandledRef = useRef(false); const { @@ -84,6 +83,7 @@ function ArticlesContent() { restoreImportRun, syncMarkdownFolder, unpublishArticle, + backfillEmbeddings, } = useArticlesAdminConvex({ workspaceId: activeWorkspace?._id, isExporting, @@ -159,6 +159,31 @@ function ArticlesContent() { } }; + const handleBackfillEmbeddings = async () => { + if (!activeWorkspace?._id) { + return; + } + + setIsBackfillingEmbeddings(true); + setBackfillError(null); + setBackfillNotice(null); + + try { + const result = await backfillEmbeddings({ + workspaceId: activeWorkspace._id, + contentTypes: ["article", "internalArticle"], + }); + setBackfillNotice( + `Embeddings backfilled: ${result.processed} processed, ${result.skipped} skipped (already existed)` + ); + } catch (error) { + console.error("Failed to backfill embeddings:", error); + setBackfillError(error instanceof Error ? error.message : "Failed to backfill embeddings."); + } finally { + setIsBackfillingEmbeddings(false); + } + }; + const buildImportPayload = async () => Promise.all( selectedImportItems.map(async (item) => ({ @@ -517,18 +542,38 @@ function ArticlesContent() {

Articles

Manage public and internal knowledge articles

-
- - - - - +
+
+ + + + +
+
+ + +
+ {backfillNotice &&

{backfillNotice}

} + {backfillError &&

{backfillError}

}
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/InboxThreadPane.tsx b/apps/web/src/app/inbox/InboxThreadPane.tsx index caa464b..cd7e3a2 100644 --- a/apps/web/src/app/inbox/InboxThreadPane.tsx +++ b/apps/web/src/app/inbox/InboxThreadPane.tsx @@ -75,10 +75,7 @@ interface InboxThreadPaneProps { onToggleKnowledgePicker: () => void; onKnowledgeSearchChange: (value: string) => void; onCloseKnowledgePicker: () => void; - onInsertKnowledgeContent: ( - item: InboxKnowledgeItem, - action?: "content" | "link" - ) => void; + onInsertKnowledgeContent: (item: InboxKnowledgeItem, action?: "content" | "link") => void; onSaveDraftAsSnippet: () => void; onUpdateSnippetFromDraft: () => void; getConversationIdentityLabel: (conversation: InboxConversation) => string; @@ -201,7 +198,7 @@ export function InboxThreadPane({ if (item.type === "article" && item.slug) { return ( -
+
- )} -

- {selectedConversation - ? getConversationIdentityLabel(selectedConversation) - : "Loading conversation..."} -

-
-
+
+
+ {isCompactViewport && ( + )} +

+ {selectedConversation + ? getConversationIdentityLabel(selectedConversation) + : "Loading conversation..."} +

+
+
+ + + {selectedConversation?.visitorId ? ( - {selectedConversation?.visitorId ? ( - - ) : null} - {!isCompactViewport && ( - + )} + {!isCompactViewport && isSidecarEnabled && ( + - )} - {!isCompactViewport && isSidecarEnabled && ( + {isSuggestionsCountLoading ? "…" : suggestionsCount} + + + )} +
+ {selectedConversation?.aiWorkflow?.state === "handoff" && ( +
+ + AI handoff + + {getHandoffReasonLabel(selectedConversation.aiWorkflow.handoffReason)} + +
+ )} + {isCompactViewport && ( +
+ + {isSidecarEnabled && (
- {selectedConversation?.aiWorkflow?.state === "handoff" && ( -
- - AI handoff - - {getHandoffReasonLabel(selectedConversation.aiWorkflow.handoffReason)} - -
- )} - {isCompactViewport && ( -
- - {isSidecarEnabled && ( - - )} -
- )} + )} +
+ + {workflowError && ( +
+ {workflowError}
+ )} - {workflowError && ( -
- {workflowError} -
- )} + {selectedConversation?.visitor?.identityVerified === false && ( +
+ + + Unverified user: This visitor's identity has not been + verified. Messages may be from an impersonator. + +
+ )} - {selectedConversation?.visitor?.identityVerified === false && ( -
- - - Unverified user: This visitor's identity has not been - verified. Messages may be from an impersonator. - +
+ {messages === undefined ? ( +
+ Loading conversation...
- )} - -
- {messages === undefined ? ( -
- Loading conversation... -
- ) : messages.length === 0 ? ( -
- No messages yet -
- ) : ( - messages.map((message) => ( + ) : messages.length === 0 ? ( +
+ No messages yet +
+ ) : ( + messages.map((message) => ( +
-
- {message.channel === "email" && message.emailMetadata?.subject && ( -
- - {message.emailMetadata.subject} + {message.channel === "email" && message.emailMetadata?.subject && ( +
+ + {message.emailMetadata.subject} +
+ )} + {message.channel === "email" && + message.senderType === "visitor" && + message.emailMetadata?.from && ( +
+ From: {message.emailMetadata.from}
)} - {message.channel === "email" && - message.senderType === "visitor" && - message.emailMetadata?.from && ( -
- From: {message.emailMetadata.from} -
- )} - {message.content.trim().length > 0 && ( -

- {message.content.replace(/<[^>]*>/g, "")} -

- )} - {message.emailMetadata?.attachments && - message.emailMetadata.attachments.length > 0 && ( -
- - {message.emailMetadata.attachments.length} attachment(s) -
- )} - {message.attachments && message.attachments.length > 0 && ( -
- {message.attachments.map((attachment) => ( -
- {attachment.url ? ( - - - - {attachment.fileName} - - - {formatSupportAttachmentSize(attachment.size)} - - - ) : ( - <> - - - {attachment.fileName} - - - {formatSupportAttachmentSize(attachment.size)} - - - )} -
- ))} + {message.content.trim().length > 0 && ( +

+ {message.content.replace(/<[^>]*>/g, "")} +

+ )} + {message.emailMetadata?.attachments && + message.emailMetadata.attachments.length > 0 && ( +
+ + {message.emailMetadata.attachments.length} attachment(s)
)} -
- - {message.channel === "email" && } - {message.senderType === "bot" - ? "Bot" - : message.senderType === "agent" - ? "You" - : "Visitor"}{" "} - • {new Date(message.createdAt).toLocaleTimeString()} - - {message.channel === "email" && - message.senderType === "agent" && - message.deliveryStatus && ( - - {message.deliveryStatus} - - )} + {message.attachments && message.attachments.length > 0 && ( +
+ {message.attachments.map((attachment) => ( +
+ {attachment.url ? ( + + + + {attachment.fileName} + + + {formatSupportAttachmentSize(attachment.size)} + + + ) : ( + <> + + + {attachment.fileName} + + + {formatSupportAttachmentSize(attachment.size)} + + + )} +
+ ))}
+ )} +
+ + {message.channel === "email" && } + {message.senderType === "bot" + ? "Bot" + : message.senderType === "agent" + ? "You" + : "Visitor"}{" "} + • {new Date(message.createdAt).toLocaleTimeString()} + + {message.channel === "email" && + message.senderType === "agent" && + message.deliveryStatus && ( + + {message.deliveryStatus} + + )}
- )) - )} -
+
+ )) + )} +
-
- {showKnowledgePicker && ( -
-
- - onKnowledgeSearchChange(event.target.value)} - placeholder="Search articles and snippets... (Ctrl+K)" - className="flex-1 text-sm outline-none" - autoFocus - /> - -
+
+ {showKnowledgePicker && ( +
+
+ + onKnowledgeSearchChange(event.target.value)} + placeholder="Search articles and snippets... (Ctrl+K)" + className="flex-1 text-sm outline-none" + autoFocus + /> + +
-
- {!isSearchingKnowledge && hasRecentContent ? ( +
+ {!isSearchingKnowledge && hasRecentContent ? ( +
+

+ Recently Used +

-

- Recently Used -

-
- {recentContent?.map((item) => renderKnowledgeItem(item))} -
+ {recentContent?.map((item) => renderKnowledgeItem(item))}
- ) : null} +
+ ) : null} - {!isSearchingKnowledge && hasSnippetLibrary ? ( + {!isSearchingKnowledge && hasSnippetLibrary ? ( +
+

+ Snippets +

+
+ {allSnippets?.map((snippet) => + renderKnowledgeItem({ + id: snippet._id, + type: "snippet", + title: snippet.name, + content: snippet.content, + snippet: snippet.shortcut + ? `/${snippet.shortcut} • ${snippet.content}` + : snippet.content, + }) + )} +
+
+ ) : null} + + {isSearchingKnowledge ? ( + hasKnowledgeResults ? (

- Snippets + Search Results

- {allSnippets?.map((snippet) => - renderKnowledgeItem({ - id: snippet._id, - type: "snippet", - title: snippet.name, - content: snippet.content, - snippet: snippet.shortcut - ? `/${snippet.shortcut} • ${snippet.content}` - : snippet.content, - }) - )} + {knowledgeResults?.map((item) => renderKnowledgeItem(item))}
- ) : null} - - {isSearchingKnowledge ? ( - hasKnowledgeResults ? ( -
-

- Search Results -

-
- {knowledgeResults?.map((item) => renderKnowledgeItem(item))} -
-
- ) : ( -

- No matching knowledge found. -

- ) - ) : null} - - {!isSearchingKnowledge && !hasRecentContent && !hasSnippetLibrary ? ( + ) : (

- Start typing to search knowledge, or save this draft as a snippet. + No matching knowledge found.

- ) : null} -
+ ) + ) : null} + + {!isSearchingKnowledge && !hasRecentContent && !hasSnippetLibrary ? ( +

+ Start typing to search knowledge, or save this draft as a snippet. +

+ ) : null}
- )} +
+ )} -
- -
- - - {/* + + {/* */} - {/* {canUpdateSnippetFromDraft ? ( + {/* {canUpdateSnippetFromDraft ? ( ) : null} */} -
-
- {pendingAttachments.length > 0 && ( -
- {pendingAttachments.map((attachment) => ( -
+
+ {pendingAttachments.length > 0 && ( +
+ {pendingAttachments.map((attachment) => ( +
+ + {attachment.fileName} + + {formatSupportAttachmentSize(attachment.size)} + + -
- ))} -
- )} - onInputChange(event.target.value)} - onKeyDown={onInputKeyDown} - placeholder="Type a message... (/ or Ctrl+K for knowledge)" - data-testid="inbox-reply-input" - disabled={isSending || isUploadingAttachments} - className="flex-1" - /> -
- + + +
+ ))} +
+ )} + onInputChange(event.target.value)} + onKeyDown={onInputKeyDown} + placeholder="Type a message... (/ or Ctrl+K for knowledge)" + data-testid="inbox-reply-input" + disabled={isSending || isUploadingAttachments} + className="flex-1" + />
+
+
) : (
diff --git a/apps/web/src/app/inbox/hooks/useInboxConvex.ts b/apps/web/src/app/inbox/hooks/useInboxConvex.ts index 2e55fc2..4c6a416 100644 --- a/apps/web/src/app/inbox/hooks/useInboxConvex.ts +++ b/apps/web/src/app/inbox/hooks/useInboxConvex.ts @@ -1,5 +1,6 @@ "use client"; +import { useEffect, useState } from "react"; import type { Id } from "@opencom/convex/dataModel"; import type { SupportAttachmentFinalizeResult } from "@opencom/web-shared"; import { @@ -144,8 +145,8 @@ const GET_SUGGESTIONS_FOR_CONVERSATION_REF = webActionRef< const CREATE_SNIPPET_REF = webMutationRef>("snippets:create"); const UPDATE_SNIPPET_REF = webMutationRef("snippets:update"); const SNIPPETS_LIST_QUERY_REF = webQueryRef("snippets:list"); -const KNOWLEDGE_SEARCH_QUERY_REF = webQueryRef( - "knowledge:search" +const KNOWLEDGE_SEARCH_ACTION_REF = webActionRef( + "knowledge:searchWithEmbeddings" ); const RECENTLY_USED_KNOWLEDGE_QUERY_REF = webQueryRef< RecentlyUsedKnowledgeArgs, @@ -177,6 +178,39 @@ export function useInboxConvex({ } : "skip"; + const searchKnowledge = useWebAction(KNOWLEDGE_SEARCH_ACTION_REF); + const [knowledgeResults, setKnowledgeResults] = useState( + undefined + ); + + useEffect(() => { + if (!workspaceId || knowledgeSearch.trim().length < 1) { + setKnowledgeResults(undefined); + return; + } + + let cancelled = false; + const timeoutId = setTimeout(() => { + searchKnowledge({ workspaceId, query: knowledgeSearch, limit: 20 }) + .then((results) => { + if (!cancelled) { + setKnowledgeResults(results); + } + }) + .catch((error) => { + console.error("Knowledge search failed:", error); + if (!cancelled) { + setKnowledgeResults(undefined); + } + }); + }, 300); + + return () => { + cancelled = true; + clearTimeout(timeoutId); + }; + }, [workspaceId, knowledgeSearch, searchKnowledge]); + return { aiResponses: useWebQuery( AI_CONVERSATION_RESPONSES_QUERY_REF, @@ -188,16 +222,9 @@ export function useInboxConvex({ createSnippet: useWebMutation(CREATE_SNIPPET_REF), convertToTicket: useWebMutation(CONVERT_CONVERSATION_TO_TICKET_REF), finalizeSupportAttachmentUpload: useWebMutation(FINALIZE_SUPPORT_ATTACHMENT_UPLOAD_REF), - generateSupportAttachmentUploadUrl: useWebMutation( - GENERATE_SUPPORT_ATTACHMENT_UPLOAD_URL_REF - ), + generateSupportAttachmentUploadUrl: useWebMutation(GENERATE_SUPPORT_ATTACHMENT_UPLOAD_URL_REF), getSuggestionsForConversation: useWebAction(GET_SUGGESTIONS_FOR_CONVERSATION_REF), - knowledgeResults: useWebQuery( - KNOWLEDGE_SEARCH_QUERY_REF, - workspaceId && knowledgeSearch.trim().length >= 1 - ? { workspaceId, query: knowledgeSearch, limit: 20 } - : "skip" - ), + knowledgeResults, markAsRead: useWebMutation(MARK_CONVERSATION_READ_REF), messages: useWebQuery( MESSAGES_LIST_QUERY_REF, 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/inbox/page.tsx b/apps/web/src/app/inbox/page.tsx index b7ed144..9145ebd 100644 --- a/apps/web/src/app/inbox/page.tsx +++ b/apps/web/src/app/inbox/page.tsx @@ -283,15 +283,18 @@ function InboxContent(): React.JSX.Element | null { } }; }, []); - const handleOpenConversationFromNotification = useCallback((conversationId: Id<"conversations">) => { - if (typeof window === "undefined") { - return; - } - const url = new URL(window.location.href); - url.pathname = "/inbox"; - url.searchParams.set("conversationId", conversationId); - window.location.assign(url.toString()); - }, []); + const handleOpenConversationFromNotification = useCallback( + (conversationId: Id<"conversations">) => { + if (typeof window === "undefined") { + return; + } + const url = new URL(window.location.href); + url.pathname = "/inbox"; + url.searchParams.set("conversationId", conversationId); + window.location.assign(url.toString()); + }, + [] + ); useInboxAttentionCues({ conversations, selectedConversationId, @@ -382,7 +385,7 @@ function InboxContent(): React.JSX.Element | null { setInputValue((prev) => `${prev}${prev ? "\n\n" : ""}${item.content}`); setLastInsertedSnippetId(item.id as Id<"snippets">); } else if (action === "link" && item.type === "article" && item.slug) { - setInputValue((prev) => `${prev}${prev ? "\n\n" : ""}[${item.title}](/help/${item.slug})`); + setInputValue((prev) => `${prev}${prev ? "\n\n" : ""}[${item.title}](article:${item.id})`); } else { setInputValue((prev) => `${prev}${prev ? "\n\n" : ""}${item.content}`); } 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} +

+ )}
diff --git a/apps/widget/src/styles.css b/apps/widget/src/styles.css index bb0aa70..eadad90 100644 --- a/apps/widget/src/styles.css +++ b/apps/widget/src/styles.css @@ -34,7 +34,13 @@ right: var(--opencom-launcher-position-right); left: var(--opencom-launcher-position-left); z-index: 10000010; - font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + font-family: + system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + Roboto, + sans-serif; } .opencom-widget-article-backdrop { @@ -74,7 +80,10 @@ box-shadow: 0 12px 24px color-mix(in srgb, var(--opencom-primary-color) 38%, transparent), inset 0 1px 0 color-mix(in srgb, white 32%, transparent); - transition: transform 0.18s ease, box-shadow 0.22s ease, filter 0.2s ease; + transition: + transform 0.18s ease, + box-shadow 0.22s ease, + filter 0.2s ease; } .opencom-launcher::before { @@ -153,7 +162,9 @@ height: 560px; background: var(--opencom-bg-surface); border-radius: 16px; - box-shadow: 0 12px 40px var(--opencom-shadow-color), 0 0 0 1px var(--opencom-border-color); + box-shadow: + 0 12px 40px var(--opencom-shadow-color), + 0 0 0 1px var(--opencom-border-color); display: flex; flex-direction: column; overflow: hidden; @@ -251,7 +262,9 @@ align-items: center; justify-content: center; opacity: 0.8; - transition: opacity 0.2s, background 0.2s; + transition: + opacity 0.2s, + background 0.2s; border-radius: 6px; } @@ -341,7 +354,11 @@ .opencom-message-user { align-self: flex-end; - background: linear-gradient(135deg, var(--opencom-primary-color) 0%, var(--opencom-primary-color) 100%); + background: linear-gradient( + 135deg, + var(--opencom-primary-color) 0%, + var(--opencom-primary-color) 100% + ); color: var(--opencom-text-on-primary); border-bottom-right-radius: 6px; box-shadow: 0 2px 4px color-mix(in srgb, var(--opencom-primary-color) 25%, transparent); @@ -489,7 +506,10 @@ border-radius: 24px; font-size: 14px; outline: none; - transition: border-color 0.2s, box-shadow 0.2s, background-color 0.2s; + transition: + border-color 0.2s, + box-shadow 0.2s, + background-color 0.2s; background-color: var(--opencom-bg-muted); color: var(--opencom-text-color); } @@ -515,14 +535,20 @@ width: 44px; height: 44px; border-radius: 50%; - background: linear-gradient(135deg, var(--opencom-primary-color) 0%, var(--opencom-primary-color) 100%); + background: linear-gradient( + 135deg, + var(--opencom-primary-color) 0%, + var(--opencom-primary-color) 100% + ); border: none; cursor: pointer; display: flex; align-items: center; justify-content: center; color: var(--opencom-text-on-primary); - transition: transform 0.15s, box-shadow 0.2s; + transition: + transform 0.15s, + box-shadow 0.2s; box-shadow: 0 2px 6px color-mix(in srgb, var(--opencom-primary-color) 30%, transparent); } @@ -538,7 +564,9 @@ align-items: center; justify-content: center; cursor: pointer; - transition: border-color 0.2s, background-color 0.2s; + transition: + border-color 0.2s, + background-color 0.2s; } .opencom-attach:hover { @@ -799,7 +827,11 @@ cursor: pointer; text-align: left; border-radius: 12px; - transition: background 0.2s, transform 0.15s, box-shadow 0.2s, border-color 0.2s; + transition: + background 0.2s, + transform 0.15s, + box-shadow 0.2s, + border-color 0.2s; display: flex; align-items: center; gap: 12px; @@ -840,7 +872,8 @@ color-mix(in srgb, var(--opencom-primary-color) 22%, white 78%) 0%, color-mix(in srgb, var(--opencom-primary-color) 10%, white 90%) 100% ); - border: 1px solid color-mix(in srgb, var(--opencom-primary-color) 22%, var(--opencom-border-color)); + border: 1px solid + color-mix(in srgb, var(--opencom-primary-color) 22%, var(--opencom-border-color)); display: flex; align-items: center; justify-content: center; @@ -933,14 +966,20 @@ .opencom-start-conv { padding: 12px 24px; - background: linear-gradient(135deg, var(--opencom-primary-color) 0%, var(--opencom-primary-color) 100%); + background: linear-gradient( + 135deg, + var(--opencom-primary-color) 0%, + var(--opencom-primary-color) 100% + ); color: var(--opencom-text-on-primary); border: none; border-radius: 10px; cursor: pointer; font-size: 14px; font-weight: 500; - transition: transform 0.15s, box-shadow 0.2s; + transition: + transform 0.15s, + box-shadow 0.2s; box-shadow: 0 2px 8px color-mix(in srgb, var(--opencom-primary-color) 30%, transparent); } @@ -963,7 +1002,9 @@ align-items: center; justify-content: center; opacity: 0.8; - transition: opacity 0.2s, background 0.2s; + transition: + opacity 0.2s, + background 0.2s; margin-right: 8px; border-radius: 6px; } @@ -987,7 +1028,9 @@ align-items: center; justify-content: center; opacity: 0.8; - transition: opacity 0.2s, background 0.2s; + transition: + opacity 0.2s, + background 0.2s; border-radius: 6px; } @@ -1197,7 +1240,9 @@ border-radius: 6px; font-size: 13px; outline: none; - transition: border-color 0.2s, box-shadow 0.2s; + transition: + border-color 0.2s, + box-shadow 0.2s; } .opencom-email-input:focus { @@ -1247,7 +1292,9 @@ align-items: center; justify-content: center; opacity: 0.8; - transition: opacity 0.2s, background 0.2s; + transition: + opacity 0.2s, + background 0.2s; border-radius: 6px; } @@ -1323,7 +1370,9 @@ justify-content: space-between; gap: 12px; cursor: pointer; - transition: background 0.2s, box-shadow 0.2s; + transition: + background 0.2s, + box-shadow 0.2s; box-shadow: 0 1px 2px var(--opencom-shadow-color); } @@ -1398,7 +1447,9 @@ cursor: pointer; text-align: left; border-radius: 10px; - transition: background 0.2s, box-shadow 0.2s; + transition: + background 0.2s, + box-shadow 0.2s; display: flex; align-items: flex-start; gap: 12px; @@ -1682,7 +1733,10 @@ cursor: pointer; text-align: left; border-radius: 12px; - transition: background 0.2s, box-shadow 0.2s, transform 0.15s; + transition: + background 0.2s, + box-shadow 0.2s, + transform 0.15s; display: flex; align-items: flex-start; gap: 14px; @@ -1801,7 +1855,9 @@ border: none; cursor: pointer; border-radius: 10px; - transition: background 0.15s, color 0.15s; + transition: + background 0.15s, + color 0.15s; color: var(--opencom-text-muted); position: relative; min-width: 56px; @@ -1889,7 +1945,10 @@ cursor: pointer; text-align: left; border-radius: 12px; - transition: background 0.2s, transform 0.15s, box-shadow 0.2s; + transition: + background 0.2s, + transform 0.15s, + box-shadow 0.2s; display: flex; align-items: center; gap: 12px; @@ -2085,8 +2144,13 @@ } @keyframes tourHighlightPulse { - 0%, 100% { box-shadow: 0 0 0 4px rgba(121, 44, 212, 0.2); } - 50% { box-shadow: 0 0 0 8px rgba(121, 44, 212, 0.1); } + 0%, + 100% { + box-shadow: 0 0 0 4px rgba(121, 44, 212, 0.2); + } + 50% { + box-shadow: 0 0 0 8px rgba(121, 44, 212, 0.1); + } } .opencom-tour-tooltip { @@ -2109,8 +2173,14 @@ } @keyframes tourTooltipFadeIn { - from { opacity: 0; transform: translateY(8px); } - to { opacity: 1; transform: translateY(0); } + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } } .opencom-tour-modal { @@ -2132,8 +2202,14 @@ } @keyframes tourModalFadeIn { - from { opacity: 0; transform: translate(-50%, -50%) scale(0.95); } - to { opacity: 1; transform: translate(-50%, -50%) scale(1); } + from { + opacity: 0; + transform: translate(-50%, -50%) scale(0.95); + } + to { + opacity: 1; + transform: translate(-50%, -50%) scale(1); + } } .opencom-tour-title { @@ -2332,8 +2408,14 @@ } @keyframes tooltipFadeIn { - from { opacity: 0; transform: translateY(4px); } - to { opacity: 1; transform: translateY(0); } + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } } .opencom-tooltip-content { @@ -2387,7 +2469,8 @@ } @keyframes beaconPulse { - 0%, 100% { + 0%, + 100% { transform: scale(1); box-shadow: 0 0 0 0 rgba(121, 44, 212, 0.4); } @@ -2405,7 +2488,13 @@ right: 0; bottom: 0; z-index: 9999999; - font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + font-family: + system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + Roboto, + sans-serif; pointer-events: none; } @@ -2434,7 +2523,9 @@ } @keyframes spin { - to { transform: rotate(360deg); } + to { + transform: rotate(360deg); + } } .opencom-authoring-error { @@ -2805,8 +2896,14 @@ } @keyframes slideUp { - from { opacity: 0; transform: translateY(10px); } - to { opacity: 1; transform: translateY(0); } + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } } .opencom-outbound-chat-bubble { @@ -2893,8 +2990,12 @@ } @keyframes fadeIn { - from { opacity: 0; } - to { opacity: 1; } + from { + opacity: 0; + } + to { + opacity: 1; + } } .opencom-outbound-post { @@ -2910,8 +3011,14 @@ } @keyframes scaleIn { - from { opacity: 0; transform: scale(0.95); } - to { opacity: 1; transform: scale(1); } + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); + } } .opencom-outbound-post-close { @@ -3017,8 +3124,12 @@ } @keyframes slideDown { - from { transform: translateY(-100%); } - to { transform: translateY(0); } + from { + transform: translateY(-100%); + } + to { + transform: translateY(0); + } } .opencom-outbound-banner.floating { @@ -4201,6 +4312,17 @@ color: #1d4ed8; } +.opencom-article-link { + color: #2563eb; + text-decoration: underline; + text-decoration-thickness: 1px; + cursor: pointer; +} + +.opencom-article-link:hover { + color: #1d4ed8; +} + .opencom-ai-source-text { color: #6b7280; font-style: italic; @@ -4263,7 +4385,9 @@ justify-content: center; gap: 4px; opacity: 0.9; - transition: opacity 0.2s, background 0.2s; + transition: + opacity 0.2s, + background 0.2s; border-radius: 6px; font-size: 12px; } @@ -4278,8 +4402,13 @@ } @keyframes pulse { - 0%, 100% { opacity: 0.6; } - 50% { opacity: 1; } + 0%, + 100% { + opacity: 0.6; + } + 50% { + opacity: 1; + } } .opencom-typing-dots { @@ -4304,8 +4433,14 @@ } @keyframes bounce { - 0%, 60%, 100% { transform: translateY(0); } - 30% { transform: translateY(-4px); } + 0%, + 60%, + 100% { + transform: translateY(0); + } + 30% { + transform: translateY(-4px); + } } /* CSAT Prompt Styles */ @@ -4366,7 +4501,9 @@ cursor: pointer; padding: 4px; color: #d1d5db; - transition: color 0.2s, transform 0.2s; + transition: + color 0.2s, + transform 0.2s; } .opencom-csat-star:hover { diff --git a/convex/_generated/ai/ai-files.state.json b/convex/_generated/ai/ai-files.state.json new file mode 100644 index 0000000..50cbe88 --- /dev/null +++ b/convex/_generated/ai/ai-files.state.json @@ -0,0 +1,13 @@ +{ + "guidelinesHash": "62d72acb9afcc18f658d88dd772f34b5b1da5fa60ef0402e57a784d97c458e57", + "agentsMdSectionHash": "bbf30bd25ceea0aefd279d62e1cb2b4c207fcb712b69adf26f3d02b296ffc7b2", + "claudeMdHash": "bbf30bd25ceea0aefd279d62e1cb2b4c207fcb712b69adf26f3d02b296ffc7b2", + "agentSkillsSha": "4de8fba05b0a506661116985649072777049f67b", + "installedSkillNames": [ + "convex-create-component", + "convex-migration-helper", + "convex-performance-audit", + "convex-quickstart", + "convex-setup-auth" + ] +} diff --git a/convex/_generated/ai/guidelines.md b/convex/_generated/ai/guidelines.md new file mode 100644 index 0000000..e41bedd --- /dev/null +++ b/convex/_generated/ai/guidelines.md @@ -0,0 +1,365 @@ +# Convex guidelines + +## Function guidelines + +### Http endpoint syntax + +- HTTP endpoints are defined in `convex/http.ts` and require an `httpAction` decorator. For example: + +```typescript +import { httpRouter } from "convex/server"; +import { httpAction } from "./_generated/server"; +const http = httpRouter(); +http.route({ + path: "/echo", + method: "POST", + handler: httpAction(async (ctx, req) => { + const body = await req.bytes(); + return new Response(body, { status: 200 }); + }), +}); +``` + +- HTTP endpoints are always registered at the exact path you specify in the `path` field. For example, if you specify `/api/someRoute`, the endpoint will be registered at `/api/someRoute`. + +### Validators + +- Below is an example of an array validator: + +```typescript +import { mutation } from "./_generated/server"; +import { v } from "convex/values"; + +export default mutation({ + args: { + simpleArray: v.array(v.union(v.string(), v.number())), + }, + handler: async (ctx, args) => { + //... + }, +}); +``` + +- Below is an example of a schema with validators that codify a discriminated union type: + +```typescript +import { defineSchema, defineTable } from "convex/server"; +import { v } from "convex/values"; + +export default defineSchema({ + results: defineTable( + v.union( + v.object({ + kind: v.literal("error"), + errorMessage: v.string(), + }), + v.object({ + kind: v.literal("success"), + value: v.number(), + }), + ), + ), +}); +``` + +- Here are the valid Convex types along with their respective validators: + Convex Type | TS/JS type | Example Usage | Validator for argument validation and schemas | Notes | + | ----------- | ------------| -----------------------| -----------------------------------------------| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| + | Id | string | `doc._id` | `v.id(tableName)` | | + | Null | null | `null` | `v.null()` | JavaScript's `undefined` is not a valid Convex value. Functions the return `undefined` or do not return will return `null` when called from a client. Use `null` instead. | + | Int64 | bigint | `3n` | `v.int64()` | Int64s only support BigInts between -2^63 and 2^63-1. Convex supports `bigint`s in most modern browsers. | + | Float64 | number | `3.1` | `v.number()` | Convex supports all IEEE-754 double-precision floating point numbers (such as NaNs). Inf and NaN are JSON serialized as strings. | + | Boolean | boolean | `true` | `v.boolean()` | + | String | string | `"abc"` | `v.string()` | Strings are stored as UTF-8 and must be valid Unicode sequences. Strings must be smaller than the 1MB total size limit when encoded as UTF-8. | + | Bytes | ArrayBuffer | `new ArrayBuffer(8)` | `v.bytes()` | Convex supports first class bytestrings, passed in as `ArrayBuffer`s. Bytestrings must be smaller than the 1MB total size limit for Convex types. | + | Array | Array | `[1, 3.2, "abc"]` | `v.array(values)` | Arrays can have at most 8192 values. | + | Object | Object | `{a: "abc"}` | `v.object({property: value})` | Convex only supports "plain old JavaScript objects" (objects that do not have a custom prototype). Objects can have at most 1024 entries. Field names must be nonempty and not start with "$" or "_". | +| Record | Record | `{"a": "1", "b": "2"}` | `v.record(keys, values)` | Records are objects at runtime, but can have dynamic keys. Keys must be only ASCII characters, nonempty, and not start with "$" or "\_". | + +### Function registration + +- Use `internalQuery`, `internalMutation`, and `internalAction` to register internal functions. These functions are private and aren't part of an app's API. They can only be called by other Convex functions. These functions are always imported from `./_generated/server`. +- Use `query`, `mutation`, and `action` to register public functions. These functions are part of the public API and are exposed to the public Internet. Do NOT use `query`, `mutation`, or `action` to register sensitive internal functions that should be kept private. +- You CANNOT register a function through the `api` or `internal` objects. +- ALWAYS include argument validators for all Convex functions. This includes all of `query`, `internalQuery`, `mutation`, `internalMutation`, `action`, and `internalAction`. + +### Function calling + +- Use `ctx.runQuery` to call a query from a query, mutation, or action. +- Use `ctx.runMutation` to call a mutation from a mutation or action. +- Use `ctx.runAction` to call an action from an action. +- ONLY call an action from another action if you need to cross runtimes (e.g. from V8 to Node). Otherwise, pull out the shared code into a helper async function and call that directly instead. +- Try to use as few calls from actions to queries and mutations as possible. Queries and mutations are transactions, so splitting logic up into multiple calls introduces the risk of race conditions. +- All of these calls take in a `FunctionReference`. Do NOT try to pass the callee function directly into one of these calls. +- When using `ctx.runQuery`, `ctx.runMutation`, or `ctx.runAction` to call a function in the same file, specify a type annotation on the return value to work around TypeScript circularity limitations. For example, + +``` +export const f = query({ + args: { name: v.string() }, + handler: async (ctx, args) => { + return "Hello " + args.name; + }, +}); + +export const g = query({ + args: {}, + handler: async (ctx, args) => { + const result: string = await ctx.runQuery(api.example.f, { name: "Bob" }); + return null; + }, +}); +``` + +### Function references + +- Use the `api` object defined by the framework in `convex/_generated/api.ts` to call public functions registered with `query`, `mutation`, or `action`. +- Use the `internal` object defined by the framework in `convex/_generated/api.ts` to call internal (or private) functions registered with `internalQuery`, `internalMutation`, or `internalAction`. +- Convex uses file-based routing, so a public function defined in `convex/example.ts` named `f` has a function reference of `api.example.f`. +- A private function defined in `convex/example.ts` named `g` has a function reference of `internal.example.g`. +- Functions can also registered within directories nested within the `convex/` folder. For example, a public function `h` defined in `convex/messages/access.ts` has a function reference of `api.messages.access.h`. + +### Pagination + +- Define pagination using the following syntax: + +```ts +import { v } from "convex/values"; +import { query, mutation } from "./_generated/server"; +import { paginationOptsValidator } from "convex/server"; +export const listWithExtraArg = query({ + args: { paginationOpts: paginationOptsValidator, author: v.string() }, + handler: async (ctx, args) => { + return await ctx.db + .query("messages") + .withIndex("by_author", (q) => q.eq("author", args.author)) + .order("desc") + .paginate(args.paginationOpts); + }, +}); +``` + +Note: `paginationOpts` is an object with the following properties: + +- `numItems`: the maximum number of documents to return (the validator is `v.number()`) +- `cursor`: the cursor to use to fetch the next page of documents (the validator is `v.union(v.string(), v.null())`) +- A query that ends in `.paginate()` returns an object that has the following properties: +- page (contains an array of documents that you fetches) +- isDone (a boolean that represents whether or not this is the last page of documents) +- continueCursor (a string that represents the cursor to use to fetch the next page of documents) + +## Schema guidelines + +- Always define your schema in `convex/schema.ts`. +- Always import the schema definition functions from `convex/server`. +- System fields are automatically added to all documents and are prefixed with an underscore. The two system fields that are automatically added to all documents are `_creationTime` which has the validator `v.number()` and `_id` which has the validator `v.id(tableName)`. +- Always include all index fields in the index name. For example, if an index is defined as `["field1", "field2"]`, the index name should be "by_field1_and_field2". +- Index fields must be queried in the same order they are defined. If you want to be able to query by "field1" then "field2" and by "field2" then "field1", you must create separate indexes. +- Do not store unbounded lists as an array field inside a document (e.g. `v.array(v.object({...}))`). As the array grows it will hit the 1MB document size limit, and every update rewrites the entire document. Instead, create a separate table for the child items with a foreign key back to the parent. +- Separate high-churn operational data (e.g. heartbeats, online status, typing indicators) from stable profile data. Storing frequently updated fields on a shared document forces every write to contend with reads of the entire document. Instead, create a dedicated table for the high-churn data with a foreign key back to the parent record. + +## Authentication guidelines + +- Convex supports JWT-based authentication through `convex/auth.config.ts`. ALWAYS create this file when using authentication. Without it, `ctx.auth.getUserIdentity()` will always return `null`. +- Example `convex/auth.config.ts`: + +```typescript +export default { + providers: [ + { + domain: "https://your-auth-provider.com", + applicationID: "convex", + }, + ], +}; +``` + +The `domain` must be the issuer URL of the JWT provider. Convex fetches `{domain}/.well-known/openid-configuration` to discover the JWKS endpoint. The `applicationID` is checked against the JWT `aud` (audience) claim. + +- Use `ctx.auth.getUserIdentity()` to get the authenticated user's identity in any query, mutation, or action. This returns `null` if the user is not authenticated, or a `UserIdentity` object with fields like `subject`, `issuer`, `name`, `email`, etc. The `subject` field is the unique user identifier. +- In Convex `UserIdentity`, `tokenIdentifier` is guaranteed and is the canonical stable identifier for the authenticated identity. For any auth-linked database lookup or ownership check, prefer `identity.tokenIdentifier` over `identity.subject`. Do NOT use `identity.subject` alone as a global identity key. +- NEVER accept a `userId` or any user identifier as a function argument for authorization purposes. Always derive the user identity server-side via `ctx.auth.getUserIdentity()`. +- When using an external auth provider with Convex on the client, use `ConvexProviderWithAuth` instead of `ConvexProvider`: + +```tsx +import { ConvexProviderWithAuth, ConvexReactClient } from "convex/react"; + +const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!); + +function App({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} +``` + +The `useAuth` prop must return `{ isLoading, isAuthenticated, fetchAccessToken }`. Do NOT use plain `ConvexProvider` when authentication is needed — it will not send tokens with requests. + +## Typescript guidelines + +- You can use the helper typescript type `Id` imported from './\_generated/dataModel' to get the type of the id for a given table. For example if there is a table called 'users' you can use `Id<'users'>` to get the type of the id for that table. +- Use `Doc<"tableName">` from `./_generated/dataModel` to get the full document type for a table. +- Use `QueryCtx`, `MutationCtx`, `ActionCtx` from `./_generated/server` for typing function contexts. NEVER use `any` for ctx parameters — always use the proper context type. +- If you need to define a `Record` make sure that you correctly provide the type of the key and value in the type. For example a validator `v.record(v.id('users'), v.string())` would have the type `Record, string>`. Below is an example of using `Record` with an `Id` type in a query: + +```ts +import { query } from "./_generated/server"; +import { Doc, Id } from "./_generated/dataModel"; + +export const exampleQuery = query({ + args: { userIds: v.array(v.id("users")) }, + handler: async (ctx, args) => { + const idToUsername: Record, string> = {}; + for (const userId of args.userIds) { + const user = await ctx.db.get("users", userId); + if (user) { + idToUsername[user._id] = user.username; + } + } + + return idToUsername; + }, +}); +``` + +- Be strict with types, particularly around id's of documents. For example, if a function takes in an id for a document in the 'users' table, take in `Id<'users'>` rather than `string`. + +## Full text search guidelines + +- A query for "10 messages in channel '#general' that best match the query 'hello hi' in their body" would look like: + +const messages = await ctx.db +.query("messages") +.withSearchIndex("search_body", (q) => +q.search("body", "hello hi").eq("channel", "#general"), +) +.take(10); + +## Query guidelines + +- Do NOT use `filter` in queries. Instead, define an index in the schema and use `withIndex` instead. +- If the user does not explicitly tell you to return all results from a query you should ALWAYS return a bounded collection instead. So that is instead of using `.collect()` you should use `.take()` or paginate on database queries. This prevents future performance issues when tables grow in an unbounded way. +- Never use `.collect().length` to count rows. Convex has no built-in count operator, so if you need a count that stays efficient at scale, maintain a denormalized counter in a separate document and update it in your mutations. +- Convex queries do NOT support `.delete()`. If you need to delete all documents matching a query, use `.take(n)` to read them in batches, iterate over each batch calling `ctx.db.delete(row._id)`, and repeat until no more results are returned. +- Convex mutations are transactions with limits on the number of documents read and written. If a mutation needs to process more documents than fit in a single transaction (e.g. bulk deletion on a large table), process a batch with `.take(n)` and then call `ctx.scheduler.runAfter(0, api.myModule.myMutation, args)` to schedule itself to continue. This way each invocation stays within transaction limits. +- Use `.unique()` to get a single document from a query. This method will throw an error if there are multiple documents that match the query. +- When using async iteration, don't use `.collect()` or `.take(n)` on the result of a query. Instead, use the `for await (const row of query)` syntax. + +### Ordering + +- By default Convex always returns documents in ascending `_creationTime` order. +- You can use `.order('asc')` or `.order('desc')` to pick whether a query is in ascending or descending order. If the order isn't specified, it defaults to ascending. +- Document queries that use indexes will be ordered based on the columns in the index and can avoid slow table scans. + +## Mutation guidelines + +- Use `ctx.db.replace` to fully replace an existing document. This method will throw an error if the document does not exist. Syntax: `await ctx.db.replace('tasks', taskId, { name: 'Buy milk', completed: false })` +- Use `ctx.db.patch` to shallow merge updates into an existing document. This method will throw an error if the document does not exist. Syntax: `await ctx.db.patch('tasks', taskId, { completed: true })` + +## Action guidelines + +- Always add `"use node";` to the top of files containing actions that use Node.js built-in modules. +- Never add `"use node";` to a file that also exports queries or mutations. Only actions can run in the Node.js runtime; queries and mutations must stay in the default Convex runtime. If you need Node.js built-ins alongside queries or mutations, put the action in a separate file. +- `fetch()` is available in the default Convex runtime. You do NOT need `"use node";` just to use `fetch()`. +- Never use `ctx.db` inside of an action. Actions don't have access to the database. +- Below is an example of the syntax for an action: + +```ts +import { action } from "./_generated/server"; + +export const exampleAction = action({ + args: {}, + handler: async (ctx, args) => { + console.log("This action does not return anything"); + return null; + }, +}); +``` + +## Scheduling guidelines + +### Cron guidelines + +- Only use the `crons.interval` or `crons.cron` methods to schedule cron jobs. Do NOT use the `crons.hourly`, `crons.daily`, or `crons.weekly` helpers. +- Both cron methods take in a FunctionReference. Do NOT try to pass the function directly into one of these methods. +- Define crons by declaring the top-level `crons` object, calling some methods on it, and then exporting it as default. For example, + +```ts +import { cronJobs } from "convex/server"; +import { internal } from "./_generated/api"; +import { internalAction } from "./_generated/server"; + +const empty = internalAction({ + args: {}, + handler: async (ctx, args) => { + console.log("empty"); + }, +}); + +const crons = cronJobs(); + +// Run `internal.crons.empty` every two hours. +crons.interval("delete inactive users", { hours: 2 }, internal.crons.empty, {}); + +export default crons; +``` + +- You can register Convex functions within `crons.ts` just like any other file. +- If a cron calls an internal function, always import the `internal` object from '\_generated/api', even if the internal function is registered in the same file. + +## Testing guidelines + +- Use `convex-test` with `vitest` and `@edge-runtime/vm` to test Convex functions. Always install the latest versions of these packages. Configure vitest with `environment: "edge-runtime"` in `vitest.config.ts`. + +Test files go inside the `convex/` directory. You must pass a module map from `import.meta.glob` to `convexTest`: + +```typescript +/// +import { convexTest } from "convex-test"; +import { expect, test } from "vitest"; +import { api } from "./_generated/api"; +import schema from "./schema"; + +const modules = import.meta.glob("./**/*.ts"); + +test("some behavior", async () => { + const t = convexTest(schema, modules); + await t.mutation(api.messages.send, { body: "Hi!", author: "Sarah" }); + const messages = await t.query(api.messages.list); + expect(messages).toMatchObject([{ body: "Hi!", author: "Sarah" }]); +}); +``` + +The `modules` argument is required so convex-test can discover and load function files. The `/// ` directive is needed for TypeScript to recognize `import.meta.glob`. + +## File storage guidelines + +- The `ctx.storage.getUrl()` method returns a signed URL for a given file. It returns `null` if the file doesn't exist. +- Do NOT use the deprecated `ctx.storage.getMetadata` call for loading a file's metadata. + +Instead, query the `_storage` system table. For example, you can use `ctx.db.system.get` to get an `Id<"_storage">`. + +``` +import { query } from "./_generated/server"; +import { Id } from "./_generated/dataModel"; + +type FileMetadata = { + _id: Id<"_storage">; + _creationTime: number; + contentType?: string; + sha256: string; + size: number; +} + +export const exampleQuery = query({ + args: { fileId: v.id("_storage") }, + handler: async (ctx, args) => { + const metadata: FileMetadata | null = await ctx.db.system.get("_storage", args.fileId); + console.log(metadata); + return null; + }, +}); +``` + +- Convex storage stores items as `Blob` objects. You must convert all items to/from a `Blob` when using Convex storage. diff --git a/docs/api-reference.md b/docs/api-reference.md index 9b7deb4..6be3bab 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -325,8 +325,59 @@ Source: `aiAgent.ts` | Function | Type | Auth | Key Args | Description | | ---------------- | -------- | ------ | ---------------------------------------------------------------------- | -------------------- | -| `getSettings` | query | member | workspaceId | Get AI configuration | -| `updateSettings` | mutation | agent | workspaceId, enabled?, model?, confidenceThreshold?, knowledgeSources? | Update AI config | +| `getSettings` | query | member | workspaceId | Get AI configuration | +| `getPublicSettings` | query | public | workspaceId | Get widget-safe AI settings for runtime use | +| `updateSettings` | mutation | agent | workspaceId, enabled?, model?, confidenceThreshold?, knowledgeSources?, suggestionsEnabled?, embeddingModel? | Update AI config | +| `getConversationResponses` | query | member or visitor | conversationId, visitorId?, sessionToken? | Get stored AI response records for a conversation | +| `listAvailableModels` | action | agent | workspaceId, selectedModel? | Discover generation-capable models for the configured AI gateway | + + The AI agent currently uses two separate model settings: + + - **[reply generation model]** + - Stored at `aiAgentSettings.model` + - Used by `aiAgentActions:generateResponse` for AI replies and handoff candidate responses + + - **[suggestions / retrieval embedding model]** + - Stored at `aiAgentSettings.embeddingModel` + - Used by suggestions and embedding-powered retrieval paths such as `suggestions:getForConversation` + + To validate that AI replies are using the selected reply model instead of a default: + + - **[save a distinctive model]** + - Save a recognizable model value in AI Agent settings. + + - **[trigger a new AI reply]** + - Send a visitor message that produces a fresh AI response. + + - **[inspect stored response metadata]** + - The `aiResponses` table persists `model` and `provider` for each stored AI response. + - `aiAgent:getConversationResponses` returns those persisted fields. + + - **[expected result]** + - The stored `model` should match the selected `aiAgentSettings.model` value that was active when the reply was generated. + + To validate the model used for suggestions: + + - **[confirm saved settings]** + - `aiAgent:getSettings` should return the selected `embeddingModel`. + + - **[trace the runtime path]** + - `suggestions:getForConversation` reads `settings.embeddingModel` and passes it into the embedding search path. + - That value is normalized by `resolveContentEmbeddingModel(...)` before the embedding provider is called. + + - **[inspect admin visibility and persisted feedback]** + - The admin suggestions panel now shows `Using embedding model: ...` for the active request. + - Usage and dismissal tracking now persist `embeddingModel` on `suggestionFeedback` records. + + Current admin UI visibility: + + - **[AI review panel]** + - The admin inbox AI review panel currently shows response text, confidence, feedback, sources, and handoff context. + - It now also displays the persisted `model` and `provider` fields returned by `aiAgent:getConversationResponses`. + + - **[suggestions UI]** + - The admin suggestions UI now displays the resolved embedding model used for the current suggestion request. + - Individual suggestion usage and dismissal events can also be validated against persisted `suggestionFeedback.embeddingModel` metadata. ## Identity Verification diff --git a/openspec/changes/archive/2026-03-13-article-link-widget-integration/.openspec.yaml b/openspec/changes/archive/2026-03-13-article-link-widget-integration/.openspec.yaml new file mode 100644 index 0000000..219e2a0 --- /dev/null +++ b/openspec/changes/archive/2026-03-13-article-link-widget-integration/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-13 diff --git a/openspec/changes/archive/2026-03-13-article-link-widget-integration/design.md b/openspec/changes/archive/2026-03-13-article-link-widget-integration/design.md new file mode 100644 index 0000000..e91653a --- /dev/null +++ b/openspec/changes/archive/2026-03-13-article-link-widget-integration/design.md @@ -0,0 +1,79 @@ +## Context + +Currently, when agents insert article links from the web inbox, the format is `[title](/help/slug)`. This link opens in a new browser tab in the widget, not in the widget's article view. The AI message sources already have a working pattern for opening articles in-widget using `onSelectArticle(articleId)`. + +Additionally, the inbox knowledge search uses simple text matching (lowercase includes) while the widget's pre-send suggestions use vector search for better semantic relevance. + +## Goals / Non-Goals + +**Goals:** + +- Article links inserted from inbox open directly in the widget's help center article view +- Widget detects article links in message content and handles them as in-widget navigation +- Inbox knowledge search uses vector search for better relevance matching + +**Non-Goals:** + +- Changing how AI sources are rendered (already works correctly) +- Changing internal article handling (internal articles are not public and don't have slugs) +- Changing the widget's pre-send article suggestions (already uses vector search) + +## Decisions + +### 1. Article Link Format + +**Decision:** Use `article:` as the link URL format (e.g., `[Article Title](article:k57f8d9g2h3j4k5l)`) + +**Rationale:** + +- Simple to parse in the widget +- Includes the article ID directly (no slug lookup needed) +- Consistent with how AI sources work (they use articleId) +- Avoids URL path conflicts with `/help/slug` format + +**Alternatives considered:** + +- `/help/slug?aid=articleId` - more complex, requires slug lookup +- `opencom://article/` - custom protocol, more complex +- Keep `/help/slug` and add data attribute - requires DOM inspection + +### 2. Widget Article Link Detection + +**Decision:** Detect `article:` protocol in the shared markdown utility and emit a custom data attribute + +**Rationale:** + +- Centralized in the shared markdown utility +- Widget can add click handler for elements with the data attribute +- Non-breaking for other surfaces (web inbox can ignore or handle differently) + +**Implementation:** + +- `packages/web-shared/src/markdown.ts` - detect `article:` URLs, emit `data-article-id` attribute +- `apps/widget/src/components/ConversationView.tsx` - add click handler for `[data-article-id]` elements + +### 3. Inbox Knowledge Search + +**Decision:** Create a new vector search query for inbox knowledge search + +**Rationale:** + +- Reuses existing `contentEmbeddings` index and embedding infrastructure +- Consistent with widget suggestions behavior +- Better semantic matching than simple text search + +**Implementation:** + +- New query `knowledge:searchWithEmbeddings` or modify existing `knowledge:search` to optionally use vector search +- Reuse embedding logic from `suggestions:searchSimilar` + +## Risks / Trade-offs + +- **Risk:** Article links in old messages won't work after format change + - **Mitigation:** Widget can detect both old `/help/slug` and new `article:` formats during transition + +- **Risk:** Vector search adds latency to inbox knowledge picker + - **Mitigation:** Use same caching/embedding approach as widget suggestions; limit results + +- **Risk:** Article ID exposure in message content + - **Mitigation:** Article IDs are already exposed in AI sources; no new security concern diff --git a/openspec/changes/archive/2026-03-13-article-link-widget-integration/proposal.md b/openspec/changes/archive/2026-03-13-article-link-widget-integration/proposal.md new file mode 100644 index 0000000..64325f7 --- /dev/null +++ b/openspec/changes/archive/2026-03-13-article-link-widget-integration/proposal.md @@ -0,0 +1,30 @@ +## Why + +When agents insert article links from the web inbox, those links open in a new browser tab in the widget instead of opening the article directly in the widget's help center view. Additionally, the inbox knowledge search uses simple text matching while the widget uses vector search for better relevance. + +## What Changes + +- Article links inserted from inbox will use a special format that includes the article ID, enabling the widget to open them directly in the help center article view (like AI message sources do) +- Widget markdown parser will detect article links and handle them as in-widget navigation instead of external links, like we do for source links in AI messages +- Inbox knowledge search will use vector search (same as widget suggestions) for better relevance matching + +## Capabilities + +### New Capabilities + +- `inbox-knowledge-vector-search`: Inbox knowledge picker uses vector search for semantic relevance matching instead of simple text matching + +### Modified Capabilities + +- `inbox-knowledge-insertion`: Article link insertion format changes from `/help/slug` to include article ID for widget integration +- `shared-markdown-rendering-sanitization`: Widget markdown rendering will detect and handle article links specially (in-widget navigation instead of external link) +- `ai-help-center-linked-sources`: Article link format in messages will be consistent with AI source link handling + +## Impact + +- `apps/web/src/app/inbox/page.tsx` - article link insertion format +- `apps/web/src/app/inbox/hooks/useInboxConvex.ts` - knowledge search query +- `apps/widget/src/utils/parseMarkdown.ts` - article link detection +- `apps/widget/src/components/ConversationView.tsx` - article link click handling +- `packages/web-shared/src/markdown.ts` - article link protocol handling +- `packages/convex/convex/knowledge.ts` - vector search integration diff --git a/openspec/changes/archive/2026-03-13-article-link-widget-integration/specs/ai-help-center-linked-sources/spec.md b/openspec/changes/archive/2026-03-13-article-link-widget-integration/specs/ai-help-center-linked-sources/spec.md new file mode 100644 index 0000000..a2457c2 --- /dev/null +++ b/openspec/changes/archive/2026-03-13-article-link-widget-integration/specs/ai-help-center-linked-sources/spec.md @@ -0,0 +1,11 @@ +## ADDED Requirements + +### Requirement: Article links in messages MUST use consistent format with AI sources + +Article links inserted by agents SHALL use the same article ID-based format as AI response sources for consistent widget navigation. + +#### Scenario: Agent-inserted article link opens in widget + +- **WHEN** a visitor clicks an article link in a message from an agent +- **THEN** the widget SHALL open the article view using the same navigation as AI sources +- **AND** the article SHALL be identified by its ID, not slug diff --git a/openspec/changes/archive/2026-03-13-article-link-widget-integration/specs/inbox-knowledge-insertion/spec.md b/openspec/changes/archive/2026-03-13-article-link-widget-integration/specs/inbox-knowledge-insertion/spec.md new file mode 100644 index 0000000..742b7bb --- /dev/null +++ b/openspec/changes/archive/2026-03-13-article-link-widget-integration/specs/inbox-knowledge-insertion/spec.md @@ -0,0 +1,24 @@ +## MODIFIED Requirements + +### Requirement: Knowledge picker MUST provide explicit insertion behavior by content type + +The consolidated picker SHALL provide insertion actions that match the selected knowledge type: snippets SHALL insert reusable reply content directly, and articles SHALL support explicit article-link insertion or content insertion without forcing agents through a separate surface. + +#### Scenario: Agent inserts a snippet from the picker + +- **WHEN** an agent selects a snippet result +- **THEN** the snippet content SHALL be inserted into the composer +- **AND** the picker SHALL close with the inserted content ready for editing or sending + +#### Scenario: Agent inserts an article link from the picker + +- **WHEN** an agent selects an article result and chooses to insert a link +- **THEN** the link SHALL be inserted in the format `[title](article:)` +- **AND** the article ID SHALL be included for widget navigation +- **AND** the picker SHALL close with the link ready for sending + +#### Scenario: Agent inserts article content from the picker + +- **WHEN** an agent selects an article result and chooses to insert content +- **THEN** the article content SHALL be inserted into the composer +- **AND** the picker SHALL close with the inserted content ready for editing or sending diff --git a/openspec/changes/archive/2026-03-13-article-link-widget-integration/specs/inbox-knowledge-vector-search/spec.md b/openspec/changes/archive/2026-03-13-article-link-widget-integration/specs/inbox-knowledge-vector-search/spec.md new file mode 100644 index 0000000..3608108 --- /dev/null +++ b/openspec/changes/archive/2026-03-13-article-link-widget-integration/specs/inbox-knowledge-vector-search/spec.md @@ -0,0 +1,27 @@ +## ADDED Requirements + +### Requirement: Inbox knowledge search MUST use vector search for semantic relevance + +The inbox knowledge picker SHALL use vector search with embeddings to find semantically relevant articles, internal articles, and snippets instead of simple text matching. + +#### Scenario: Agent searches for knowledge with semantic query + +- **WHEN** an agent enters a query in the inbox knowledge picker +- **THEN** the search SHALL use vector embeddings to find semantically relevant content +- **AND** results SHALL be ranked by semantic similarity score + +#### Scenario: Vector search returns mixed content types + +- **WHEN** vector search finds matching content +- **THEN** results SHALL include articles, internal articles, and snippets +- **AND** each result SHALL include the content type, title, snippet preview, and article ID + +### Requirement: Inbox knowledge search MUST filter by workspace + +Vector search results SHALL be scoped to the current workspace to prevent cross-workspace data leakage. + +#### Scenario: Agent searches within workspace context + +- **WHEN** an agent performs a knowledge search +- **THEN** results SHALL only include content from the agent's current workspace +- **AND** the workspace filter SHALL be applied at the vector search level diff --git a/openspec/changes/archive/2026-03-13-article-link-widget-integration/specs/shared-markdown-rendering-sanitization/spec.md b/openspec/changes/archive/2026-03-13-article-link-widget-integration/specs/shared-markdown-rendering-sanitization/spec.md new file mode 100644 index 0000000..d1c149a --- /dev/null +++ b/openspec/changes/archive/2026-03-13-article-link-widget-integration/specs/shared-markdown-rendering-sanitization/spec.md @@ -0,0 +1,44 @@ +## MODIFIED Requirements + +### Requirement: Web and widget MUST consume a shared markdown rendering implementation + +Markdown rendering and sanitization SHALL be implemented in a shared utility module consumed by both web and widget surfaces. + +#### Scenario: Core parser behavior update + +- **WHEN** parser settings (for example breaks/linkify behavior) are changed +- **THEN** the update SHALL be made in the shared utility +- **AND** both web and widget SHALL consume the updated behavior through shared imports + +### Requirement: Shared sanitization policy MUST enforce equivalent safety guarantees + +The shared utility MUST apply one canonical sanitization and link-hardening policy for supported markdown content. + +#### Scenario: Unsafe protocol appears in markdown link + +- **WHEN** markdown contains a link with a disallowed protocol +- **THEN** rendered output SHALL remove or neutralize that unsafe link target +- **AND** surrounding content SHALL still render safely + +#### Scenario: Allowed markdown image/link content is rendered + +- **WHEN** markdown includes allowed link and image content +- **THEN** rendering SHALL preserve allowed elements and attributes according to the shared policy + +## ADDED Requirements + +### Requirement: Shared markdown utility MUST detect article links for in-widget navigation + +The shared markdown utility SHALL detect `article:` protocol links and emit metadata for in-widget article navigation. + +#### Scenario: Article link is rendered with navigation metadata + +- **WHEN** markdown contains a link in the format `[title](article:)` +- **THEN** the rendered anchor SHALL include a `data-article-id` attribute with the article ID +- **AND** the link SHALL NOT have `target="_blank"` (in-widget navigation) + +#### Scenario: Article link click is handled by widget + +- **WHEN** a visitor clicks an article link in the widget +- **THEN** the widget SHALL call `onSelectArticle(articleId)` to open the article view +- **AND** the link SHALL NOT open in a new browser tab diff --git a/openspec/changes/archive/2026-03-13-article-link-widget-integration/tasks.md b/openspec/changes/archive/2026-03-13-article-link-widget-integration/tasks.md new file mode 100644 index 0000000..5728153 --- /dev/null +++ b/openspec/changes/archive/2026-03-13-article-link-widget-integration/tasks.md @@ -0,0 +1,46 @@ +## 1. Backend: Inbox Knowledge Vector Search + +- [x] 1.1 Create `knowledge:searchWithEmbeddings` action in `packages/convex/convex/knowledge.ts` using vector search +- [x] 1.2 Add embedding generation and vector search logic (reuse from `suggestions:searchSimilar`) +- [x] 1.3 Ensure workspace filtering at vector search level +- [x] 1.4 Return results with content type, title, snippet, article ID, and relevance score +- [x] 1.5 Add Convex typecheck and tests for new query + +## 2. Web Inbox: Knowledge Search Integration + +- [x] 2.1 Update `apps/web/src/app/inbox/hooks/useInboxConvex.ts` to use new vector search query +- [x] 2.2 Update `InboxKnowledgeItem` type to include article ID field +- [x] 2.3 Run web typecheck + +## 3. Web Inbox: Article Link Format + +- [x] 3.1 Update `apps/web/src/app/inbox/page.tsx` `handleInsertKnowledgeContent` to use `article:` format +- [x] 3.2 Ensure article ID is included in the link for public articles +- [x] 3.3 Run web typecheck + +## 4. Shared Markdown: Article Link Detection + +- [x] 4.1 Update `packages/web-shared/src/markdown.ts` to detect `article:` protocol URLs +- [x] 4.2 Emit `data-article-id` attribute for article links +- [x] 4.3 Remove `target="_blank"` for article links (in-widget navigation) +- [x] 4.4 Add `data-article-id` to `ALLOWED_ATTR` list +- [x] 4.5 Add tests for article link rendering +- [x] 4.6 Run web-shared tests +- [x] 4.7 Add `opencom-article-link` class for styling +- [x] 4.8 Configure DOMPurify to allow `article:` protocol + +## 5. Widget: Article Link Click Handling + +- [x] 5.1 Update `apps/widget/src/components/ConversationView.tsx` to add click handler for `[data-article-id]` elements +- [x] 5.2 Call `onSelectArticle(articleId)` when article link is clicked +- [x] 5.3 Prevent default link behavior for article links +- [x] 5.4 Add tests for article link click handling +- [x] 5.5 Run widget typecheck and tests +- [x] 5.6 Add CSS for `.opencom-article-link` class + +## 6. Verification + +- [x] 6.1 Run full workspace typecheck +- [x] 6.2 Run relevant package tests +- [x] 6.3 Manual test: Insert article link from inbox, verify opens in widget +- [x] 6.4 Manual test: Verify vector search returns relevant results in inbox diff --git a/openspec/specs/ai-help-center-linked-sources/spec.md b/openspec/specs/ai-help-center-linked-sources/spec.md index 5448a92..e5cddd8 100644 --- a/openspec/specs/ai-help-center-linked-sources/spec.md +++ b/openspec/specs/ai-help-center-linked-sources/spec.md @@ -1,6 +1,7 @@ # ai-help-center-linked-sources Specification ## Purpose + TBD - created by archiving change add-help-center-links-in-ai-responses. Update Purpose after archive. ## Requirements @@ -31,3 +32,13 @@ Sources without linkable article targets SHALL remain visible as attribution tex - **WHEN** AI response includes a non-article source - **THEN** UI SHALL display source attribution without invalid navigation affordances + +### Requirement: Article links in messages MUST use consistent format with AI sources + +Article links inserted by agents SHALL use the same article ID-based format as AI response sources for consistent widget navigation. + +#### Scenario: Agent-inserted article link opens in widget + +- **WHEN** a visitor clicks an article link in a message from an agent +- **THEN** the widget SHALL open the article view using the same navigation as AI sources +- **AND** the article SHALL be identified by its ID, not slug diff --git a/openspec/specs/inbox-knowledge-insertion/spec.md b/openspec/specs/inbox-knowledge-insertion/spec.md index fc93b23..53ca97d 100644 --- a/openspec/specs/inbox-knowledge-insertion/spec.md +++ b/openspec/specs/inbox-knowledge-insertion/spec.md @@ -1,44 +1,62 @@ # inbox-knowledge-insertion Specification ## Purpose + TBD - created by archiving change simplify-knowledge-content-management. Update Purpose after archive. + ## Requirements + ### Requirement: Inbox MUST provide one consolidated knowledge picker + The inbox composer SHALL expose a single searchable knowledge picker for snippets, public articles, and internal articles instead of separate snippet, article-link, and knowledge search controls. #### Scenario: Agent searches one picker for mixed knowledge + - **WHEN** an agent opens the inbox knowledge picker and enters a query - **THEN** the picker SHALL return matching snippets, public articles, and internal articles in one result list - **AND** each result SHALL display a type label so the agent can distinguish what will be inserted #### Scenario: Keyboard shortcut opens the consolidated picker + - **WHEN** an agent uses the inbox keyboard shortcut for knowledge lookup - **THEN** the consolidated knowledge picker SHALL open - **AND** the inbox composer SHALL not open a separate snippet-only or article-only picker for that action ### Requirement: Knowledge picker MUST provide explicit insertion behavior by content type + The consolidated picker SHALL provide insertion actions that match the selected knowledge type: snippets SHALL insert reusable reply content directly, and articles SHALL support explicit article-link insertion or content insertion without forcing agents through a separate surface. #### Scenario: Agent inserts a snippet from the picker + - **WHEN** an agent selects a snippet result - **THEN** the snippet content SHALL be inserted into the composer - **AND** the picker SHALL close with the inserted content ready for editing or sending -#### Scenario: Agent inserts an article from the picker -- **WHEN** an agent selects an article result -- **THEN** the picker SHALL offer the article insertion action configured for that result type -- **AND** the agent SHALL be able to complete article insertion without opening a separate article search control +#### Scenario: Agent inserts an article link from the picker + +- **WHEN** an agent selects an article result and chooses to insert a link +- **THEN** the link SHALL be inserted in the format `[title](article:)` +- **AND** the article ID SHALL be included for widget navigation +- **AND** the picker SHALL close with the link ready for sending + +#### Scenario: Agent inserts article content from the picker + +- **WHEN** an agent selects an article result and chooses to insert content +- **THEN** the article content SHALL be inserted into the composer +- **AND** the picker SHALL close with the inserted content ready for editing or sending ### Requirement: Agents MUST complete common snippet workflows without leaving inbox + Agents SHALL be able to create a new snippet from the current draft and update an existing snippet from inbox so routine snippet workflows do not depend on a dedicated snippet screen during active support work. #### Scenario: Agent saves a draft reply as a new snippet + - **WHEN** an agent chooses to save the current inbox draft as a snippet - **THEN** the inbox workflow SHALL collect the required snippet metadata - **AND** the new snippet SHALL become available in subsequent knowledge picker searches without leaving inbox #### Scenario: Agent updates an existing snippet from inbox + - **WHEN** an agent edits a snippet from inbox after selecting it from the knowledge picker - **THEN** the workflow SHALL allow the agent to update that snippet's saved content - **AND** future snippet insertions SHALL use the updated content - diff --git a/openspec/specs/inbox-knowledge-vector-search/spec.md b/openspec/specs/inbox-knowledge-vector-search/spec.md new file mode 100644 index 0000000..179ea80 --- /dev/null +++ b/openspec/specs/inbox-knowledge-vector-search/spec.md @@ -0,0 +1,33 @@ +# inbox-knowledge-vector-search Specification + +## Purpose + +TBD - created by archiving change article-link-widget-integration. Update Purpose after archive. + +## Requirements + +### Requirement: Inbox knowledge search MUST use vector search for semantic relevance + +The inbox knowledge picker SHALL use vector search with embeddings to find semantically relevant articles, internal articles, and snippets instead of simple text matching. + +#### Scenario: Agent searches for knowledge with semantic query + +- **WHEN** an agent enters a query in the inbox knowledge picker +- **THEN** the search SHALL use vector embeddings to find semantically relevant content +- **AND** results SHALL be ranked by semantic similarity score + +#### Scenario: Vector search returns mixed content types + +- **WHEN** vector search finds matching content +- **THEN** results SHALL include articles, internal articles, and snippets +- **AND** each result SHALL include the content type, title, snippet preview, and article ID + +### Requirement: Inbox knowledge search MUST filter by workspace + +Vector search results SHALL be scoped to the current workspace to prevent cross-workspace data leakage. + +#### Scenario: Agent searches within workspace context + +- **WHEN** an agent performs a knowledge search +- **THEN** results SHALL only include content from the agent's current workspace +- **AND** the workspace filter SHALL be applied at the vector search level diff --git a/openspec/specs/shared-markdown-rendering-sanitization/spec.md b/openspec/specs/shared-markdown-rendering-sanitization/spec.md index aad3c55..fefea22 100644 --- a/openspec/specs/shared-markdown-rendering-sanitization/spec.md +++ b/openspec/specs/shared-markdown-rendering-sanitization/spec.md @@ -1,8 +1,11 @@ # shared-markdown-rendering-sanitization Specification ## Purpose + TBD - created by archiving change unify-markdown-rendering-utility. Update Purpose after archive. + ## Requirements + ### Requirement: Web and widget MUST consume a shared markdown rendering implementation Markdown rendering and sanitization SHALL be implemented in a shared utility module consumed by both web and widget surfaces. @@ -37,3 +40,18 @@ The shared utility SHALL support frontmatter stripping and plain-text excerpt he - **WHEN** input markdown begins with frontmatter metadata - **THEN** rendered output and excerpt generation SHALL ignore the frontmatter block +### Requirement: Shared markdown utility MUST detect article links for in-widget navigation + +The shared markdown utility SHALL detect `article:` protocol links and emit metadata for in-widget article navigation. + +#### Scenario: Article link is rendered with navigation metadata + +- **WHEN** markdown contains a link in the format `[title](article:)` +- **THEN** the rendered anchor SHALL include a `data-article-id` attribute with the article ID +- **AND** the link SHALL NOT have `target="_blank"` (in-widget navigation) + +#### Scenario: Article link click is handled by widget + +- **WHEN** a visitor clicks an article link in the widget +- **THEN** the widget SHALL call `onSelectArticle(articleId)` to open the article view +- **AND** the link SHALL NOT open in a new browser tab diff --git a/package.json b/package.json index a2a9e30..ad73a8b 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,7 @@ "@typescript-eslint/parser": "^6.13.0", "@vitest/coverage-v8": "^4.0.17", "@vitest/ui": "^4.0.17", - "convex": "^1.32.0", + "convex": "1.35.1", "eslint": "^8.55.0", "eslint-plugin-react": "^7.37.0", "eslint-plugin-react-hooks": "^5.0.0", @@ -82,15 +82,24 @@ "pnpm": { "overrides": { "@isaacs/brace-expansion": "5.0.1", + "@xmldom/xmldom@0.7.13": "0.8.12", + "@xmldom/xmldom@0.8.11": "0.8.12", "esbuild": "^0.27.0", + "fast-xml-parser": "4.5.5", + "flatted": "3.4.2", "minimatch": "10.2.3", + "node-forge": "1.4.0", "@expo/cli>minimatch": "10.2.3", "@expo/fingerprint>minimatch": "10.2.3", "@expo/metro-config>minimatch": "10.2.3", "glob@13.0.0>minimatch": "10.2.3", "glob@10.5.0>minimatch": "10.2.3", + "picomatch@2.3.1": "2.3.2", + "picomatch@3.0.1": "3.0.2", + "picomatch@4.0.3": "4.0.4", "rollup": "4.59.0", "tar": "7.5.11", + "vite@7.3.1": "7.3.2", "@react-native-community/cli": "17.0.1", "@react-native-community/cli-server-api": "17.0.1", "@react-native-community/cli-tools": "17.0.1", diff --git a/packages/convex/convex/_generated/api.d.ts b/packages/convex/convex/_generated/api.d.ts index 7c39510..07bd412 100644 --- a/packages/convex/convex/_generated/api.d.ts +++ b/packages/convex/convex/_generated/api.d.ts @@ -52,6 +52,7 @@ import type * as internalArticles from "../internalArticles.js"; import type * as knowledge from "../knowledge.js"; import type * as lib_aiGateway from "../lib/aiGateway.js"; import type * as lib_authWrappers from "../lib/authWrappers.js"; +import type * as lib_embeddingModels from "../lib/embeddingModels.js"; import type * as lib_notificationPreferences from "../lib/notificationPreferences.js"; import type * as lib_seriesRuntimeAdapter from "../lib/seriesRuntimeAdapter.js"; import type * as lib_unifiedArticles from "../lib/unifiedArticles.js"; @@ -238,6 +239,7 @@ declare const fullApi: ApiFromModules<{ knowledge: typeof knowledge; "lib/aiGateway": typeof lib_aiGateway; "lib/authWrappers": typeof lib_authWrappers; + "lib/embeddingModels": typeof lib_embeddingModels; "lib/notificationPreferences": typeof lib_notificationPreferences; "lib/seriesRuntimeAdapter": typeof lib_seriesRuntimeAdapter; "lib/unifiedArticles": typeof lib_unifiedArticles; diff --git a/packages/convex/convex/aiAgent.ts b/packages/convex/convex/aiAgent.ts index 3b95393..bb114b5 100644 --- a/packages/convex/convex/aiAgent.ts +++ b/packages/convex/convex/aiAgent.ts @@ -12,6 +12,8 @@ import { Doc, Id } from "./_generated/dataModel"; import { getAuthenticatedUserFromSession } from "./auth"; import { getWorkspaceMembership, requirePermission } from "./permissions"; import { authAction, authMutation, authQuery } from "./lib/authWrappers"; +import { DEFAULT_CONTENT_EMBEDDING_MODEL, resolveContentEmbeddingModel } from "./lib/embeddingModels"; +import { getAIGatewayApiKey, getAIBaseURL, getAIGatewayProviderLabel } from "./lib/aiGateway"; import { getShallowRunAfter, routeEventRef } from "./notifications/functionRefs"; import { resolveVisitorFromSession } from "./widgetSessions"; @@ -31,6 +33,41 @@ type RuntimeKnowledgeResult = { relevanceScore: number; }; +type AvailableAIModel = { + id: string; + name: string; + provider: string; +}; + +type OpenAIModelListResponse = { + data?: Array<{ + id?: string; + created?: number; + }>; +}; + +type StoredAIResponse = Doc<"aiResponses">; +type StoredAIResponseSource = StoredAIResponse["sources"][number]; +type StoredAIResponseWithMessage = StoredAIResponse & { + messageId: Id<"messages">; +}; + +type ConversationResponse = Omit & { + messageId: Id<"messages">; + deliveredResponseContext: { + response: string; + sources: StoredAIResponseSource[]; + confidence: number | null; + }; + generatedResponseContext: + | { + response: string; + sources: StoredAIResponseSource[]; + confidence: number; + } + | null; +}; + type GetRelevantKnowledgeForRuntimeActionArgs = { workspaceId: Id<"workspaces">; query: string; @@ -77,10 +114,125 @@ const DEFAULT_AI_SETTINGS = { workingHours: null, model: "openai/gpt-5-nano", suggestionsEnabled: false, - embeddingModel: "text-embedding-3-small", + embeddingModel: DEFAULT_CONTENT_EMBEDDING_MODEL, lastConfigError: null, }; +const AVAILABLE_MODEL_DISCOVERY_TIMEOUT_MS = 5000; +const NON_GENERATION_MODEL_PREFIXES = [ + "text-embedding-", + "omni-moderation-", + "whisper-", + "tts-", + "gpt-image-", + "dall-e-", + "babbage-", + "davinci-", +]; + +function normalizeAvailableModelId(value: string | undefined): string | null { + const normalized = value?.trim(); + if (!normalized) { + return null; + } + + return normalized; +} + +function createAvailableAIModel(value: string | undefined): AvailableAIModel | null { + const normalizedId = normalizeAvailableModelId(value); + if (!normalizedId) { + return null; + } + + const defaultProvider = getAIGatewayProviderLabel(getAIBaseURL(getAIGatewayApiKey())); + const providerSeparatorIndex = normalizedId.indexOf("/"); + if (providerSeparatorIndex === -1) { + return { + id: normalizedId, + name: normalizedId, + provider: defaultProvider, + }; + } + + const provider = normalizedId.slice(0, providerSeparatorIndex).trim(); + const model = normalizedId.slice(providerSeparatorIndex + 1).trim(); + return { + id: normalizedId, + name: model || normalizedId, + provider: provider || defaultProvider, + }; +} + +function isLikelyGenerationModel(modelId: string): boolean { + const normalized = modelId.trim().toLowerCase(); + if (!normalized) { + return false; + } + + const modelPathParts = normalized.split("/").map((part) => part.trim()).filter(Boolean); + const modelName = modelPathParts[modelPathParts.length - 1] ?? ""; + if (!modelName) { + return false; + } + + return !NON_GENERATION_MODEL_PREFIXES.some((prefix) => modelName.startsWith(prefix)); +} + +function dedupeAvailableAIModels(models: AvailableAIModel[]): AvailableAIModel[] { + const seen = new Set(); + return models.filter((model) => { + if (seen.has(model.id)) { + return false; + } + + seen.add(model.id); + return true; + }); +} + +async function discoverAvailableAIModels(): Promise { + const apiKey = getAIGatewayApiKey(); + if (!apiKey) { + return null; + } + + const providerLabel = getAIGatewayProviderLabel(getAIBaseURL(apiKey)); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), AVAILABLE_MODEL_DISCOVERY_TIMEOUT_MS); + try { + const response = await fetch(`${getAIBaseURL(apiKey)}/models`, { + headers: { + Accept: "application/json", + Authorization: `Bearer ${apiKey}`, + }, + signal: controller.signal, + }); + if (!response.ok) { + return null; + } + + const payload = (await response.json()) as OpenAIModelListResponse; + const models = Array.isArray(payload.data) ? payload.data : []; + + return models + .filter( + (model): model is { id: string; created?: number } => + typeof model.id === "string" && isLikelyGenerationModel(model.id) + ) + .sort((left, right) => (right.created ?? 0) - (left.created ?? 0) || left.id.localeCompare(right.id)) + .map((model) => + createAvailableAIModel(model.id.includes("/") ? model.id : `${providerLabel}/${model.id}`) + ) + .filter((model): model is AvailableAIModel => model !== null); + } catch { + return null; + } finally { + clearTimeout(timeout); + } +} + function withAISettingDefaults(settings: Doc<"aiAgentSettings"> | null): { _id?: Id<"aiAgentSettings">; _creationTime?: number; @@ -115,7 +267,9 @@ function withAISettingDefaults(settings: Doc<"aiAgentSettings"> | null): { handoffMessage: settings.handoffMessage ?? DEFAULT_AI_SETTINGS.handoffMessage, workingHours: settings.workingHours ?? null, suggestionsEnabled: settings.suggestionsEnabled ?? false, - embeddingModel: settings.embeddingModel ?? DEFAULT_AI_SETTINGS.embeddingModel, + embeddingModel: resolveContentEmbeddingModel( + settings.embeddingModel ?? DEFAULT_AI_SETTINGS.embeddingModel + ), lastConfigError: settings.lastConfigError ?? null, }; } @@ -129,6 +283,7 @@ async function getWorkspaceAISettings( .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId)) .first(); } + async function requireConversationAccess( ctx: QueryCtx | MutationCtx, args: { @@ -241,7 +396,7 @@ export const updateSettings = authMutation({ embeddingModel: v.optional(v.string()), }, permission: "settings.workspace", - handler: async (ctx, args) => { + handler: async (ctx, args): Promise> => { const now = Date.now(); const existing = await ctx.db @@ -264,7 +419,8 @@ export const updateSettings = authMutation({ if (args.model !== undefined) updates.model = args.model; if (args.suggestionsEnabled !== undefined) updates.suggestionsEnabled = args.suggestionsEnabled; - if (args.embeddingModel !== undefined) updates.embeddingModel = args.embeddingModel; + if (args.embeddingModel !== undefined) + updates.embeddingModel = resolveContentEmbeddingModel(args.embeddingModel); await ctx.db.patch(existing._id, updates); return existing._id; @@ -281,7 +437,9 @@ export const updateSettings = authMutation({ workingHours: args.workingHours ?? undefined, model: args.model ?? "openai/gpt-5-nano", suggestionsEnabled: args.suggestionsEnabled ?? false, - embeddingModel: args.embeddingModel ?? "text-embedding-3-small", + embeddingModel: resolveContentEmbeddingModel( + args.embeddingModel ?? DEFAULT_CONTENT_EMBEDDING_MODEL + ), createdAt: now, updatedAt: now, }); @@ -382,7 +540,7 @@ export const getRelevantKnowledgeForRuntime = internalQuery({ knowledgeSources: v.optional(v.array(knowledgeSourceValidator)), limit: v.optional(v.number()), }, - handler: async () => { + handler: async (): Promise => { return []; }, }); @@ -408,7 +566,7 @@ export const storeResponse = mutation({ model: v.string(), provider: v.string(), }, - handler: async (ctx, args) => { + handler: async (ctx, args): Promise> => { await requireConversationAccess(ctx, { conversationId: args.conversationId, visitorId: args.visitorId, @@ -454,7 +612,7 @@ export const submitFeedback = mutation({ visitorId: v.optional(v.id("visitors")), sessionToken: v.optional(v.string()), }, - handler: async (ctx, args) => { + handler: async (ctx, args): Promise => { const response = await ctx.db.get(args.responseId); if (!response) { throw new Error("AI response not found"); @@ -477,7 +635,7 @@ export const getConversationResponses = query({ visitorId: v.optional(v.id("visitors")), sessionToken: v.optional(v.string()), }, - handler: async (ctx, args) => { + handler: async (ctx, args): Promise => { await requireConversationAccess(ctx, { conversationId: args.conversationId, visitorId: args.visitorId, @@ -489,22 +647,26 @@ export const getConversationResponses = query({ .withIndex("by_conversation", (q) => q.eq("conversationId", args.conversationId)) .collect(); - return responses.map((response) => ({ - ...response, - deliveredResponseContext: { - response: response.response, - sources: response.sources, - confidence: response.handedOff ? null : response.confidence, - }, - generatedResponseContext: - response.generatedCandidateResponse === undefined - ? null - : { - response: response.generatedCandidateResponse, - sources: response.generatedCandidateSources ?? [], - confidence: response.generatedCandidateConfidence ?? response.confidence, - }, - })); + return responses + .filter( + (response): response is StoredAIResponseWithMessage => response.messageId !== undefined + ) + .map((response) => ({ + ...response, + deliveredResponseContext: { + response: response.response, + sources: response.sources, + confidence: response.handedOff ? null : response.confidence, + }, + generatedResponseContext: + response.generatedCandidateResponse === undefined + ? null + : { + response: response.generatedCandidateResponse, + sources: response.generatedCandidateSources ?? [], + confidence: response.generatedCandidateConfidence ?? response.confidence, + }, + })); }, }); @@ -711,20 +873,19 @@ export const getAnalytics = authQuery({ }); // List available AI models (from AI Gateway) -export const listAvailableModels = query({ - args: {}, - handler: async () => { - // Return a static list of supported models - // In production, this could query the AI Gateway API - return [ - { id: "openai/gpt-5-nano", name: "GPT-5.1 Mini", provider: "openai" }, - // { id: "openai/gpt-5.1", name: "GPT-5.1", provider: "openai" }, - // { id: "anthropic/claude-3-haiku-20240307", name: "Claude 3 Haiku", provider: "anthropic" }, - // { - // id: "anthropic/claude-3-5-sonnet-20241022", - // name: "Claude 3.5 Sonnet", - // provider: "anthropic", - // }, - ]; +export const listAvailableModels = authAction({ + args: { + workspaceId: v.id("workspaces"), + selectedModel: v.optional(v.string()), + }, + permission: "settings.workspace", + handler: async (_ctx, args): Promise => { + const discoveredModels = await discoverAvailableAIModels(); + const fallbackModels = [ + createAvailableAIModel(args.selectedModel), + createAvailableAIModel(DEFAULT_AI_SETTINGS.model), + ].filter((model): model is AvailableAIModel => model !== null); + + return dedupeAvailableAIModels([...(discoveredModels ?? []), ...fallbackModels]); }, }); diff --git a/packages/convex/convex/aiAgentActions.ts b/packages/convex/convex/aiAgentActions.ts index 2830050..10e977d 100644 --- a/packages/convex/convex/aiAgentActions.ts +++ b/packages/convex/convex/aiAgentActions.ts @@ -5,7 +5,7 @@ import { v } from "convex/values"; import type { Id } from "./_generated/dataModel"; import { action } from "./_generated/server"; import { generateText } from "ai"; -import { createAIClient } from "./lib/aiGateway"; +import { createAIClient, getAIGatewayProviderLabel } from "./lib/aiGateway"; type AIConfigurationDiagnostic = { code: string; @@ -242,7 +242,13 @@ function getShallowRunAction(ctx: { runAction: unknown }) { ) => Promise; } -const SUPPORTED_AI_PROVIDERS = new Set(["openai"]); +function maybeGetShallowRunAction(ctx: { runAction?: unknown }) { + if (typeof ctx.runAction !== "function") { + return null; + } + return getShallowRunAction(ctx as { runAction: unknown }); +} + const GENERATION_FAILURE_FALLBACK_RESPONSE = "I'm having trouble processing your request right now. Let me connect you with a human agent."; const EMPTY_RESPONSE_RETRY_LIMIT = 1; @@ -263,17 +269,20 @@ const isGPT5ReasoningModel = (provider: string, model: string): boolean => // Parse model string to get provider and model name export const parseModel = (modelString: string): { provider: string; model: string } => { - const parts = modelString.split("/"); + const trimmedModel = modelString.trim(); + const parts = trimmedModel.split("/"); if (parts.length === 2) { return { provider: parts[0], model: parts[1] }; } - return { provider: "openai", model: modelString }; + + return { provider: getAIGatewayProviderLabel(), model: trimmedModel }; }; export const getAIConfigurationDiagnostic = ( modelString: string, - environment: { aiGatewayApiKey?: string } = { + environment: { aiGatewayApiKey?: string; aiGatewayProviderLabel?: string } = { aiGatewayApiKey: process.env.AI_GATEWAY_API_KEY, + aiGatewayProviderLabel: getAIGatewayProviderLabel(), } ): AIConfigurationDiagnostic | null => { const trimmedModel = modelString.trim(); @@ -284,21 +293,24 @@ export const getAIConfigurationDiagnostic = ( }; } + const providerLabel = environment.aiGatewayProviderLabel ?? getAIGatewayProviderLabel(); const parts = trimmedModel.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { + if (parts.length > 2 || (parts.length === 2 && (!parts[0] || !parts[1]))) { return { code: "INVALID_MODEL_FORMAT", - message: "AI model format is invalid. Use provider/model (for example openai/gpt-5-nano).", + message: + "AI model format is invalid. Use provider/model or a raw model ID exposed by the configured AI gateway.", model: trimmedModel, }; } - const provider = parts[0]; - if (!SUPPORTED_AI_PROVIDERS.has(provider)) { + const provider = parts.length === 2 ? parts[0] : providerLabel; + const model = parts.length === 2 ? parts[1] : trimmedModel; + if (!provider || !model) { return { - code: "UNSUPPORTED_PROVIDER", - message: `Provider '${provider}' is not supported in this runtime.`, - provider, + code: "INVALID_MODEL_FORMAT", + message: + "AI model format is invalid. Use provider/model or a raw model ID exposed by the configured AI gateway.", model: trimmedModel, }; } @@ -473,7 +485,7 @@ export const generateResponse = action({ const runQuery = getShallowRunQuery(ctx); const runMutation = getShallowRunMutation(ctx); - const runAction = getShallowRunAction(ctx); + const runAction = maybeGetShallowRunAction(ctx); const access = await runQuery(AUTHORIZE_CONVERSATION_ACCESS_REF, { conversationId: args.conversationId, visitorId: args.visitorId, @@ -576,19 +588,21 @@ export const generateResponse = action({ // Get relevant knowledge let knowledgeResults: RelevantKnowledgeResult[] = []; - try { - knowledgeResults = await runAction(GET_RELEVANT_KNOWLEDGE_FOR_RUNTIME_ACTION_REF, { - workspaceId: args.workspaceId, - query: args.query, - knowledgeSources: settings.knowledgeSources, - limit: 5, - embeddingModel: settings.embeddingModel, - }); - } catch (retrievalError) { - console.error( - "Knowledge retrieval failed; continuing without knowledge context:", - retrievalError - ); + if (runAction) { + try { + knowledgeResults = await runAction(GET_RELEVANT_KNOWLEDGE_FOR_RUNTIME_ACTION_REF, { + workspaceId: args.workspaceId, + query: args.query, + knowledgeSources: settings.knowledgeSources, + limit: 5, + embeddingModel: settings.embeddingModel, + }); + } catch (retrievalError) { + console.error( + "Knowledge retrieval failed; continuing without knowledge context:", + retrievalError + ); + } } // Build knowledge context for prompt diff --git a/packages/convex/convex/aiAgentActionsKnowledge.ts b/packages/convex/convex/aiAgentActionsKnowledge.ts index 67debcb..fdf6809 100644 --- a/packages/convex/convex/aiAgentActionsKnowledge.ts +++ b/packages/convex/convex/aiAgentActionsKnowledge.ts @@ -2,10 +2,10 @@ import { internalAction } from "./_generated/server"; import { v } from "convex/values"; import { embed } from "ai"; import { createAIClient } from "./lib/aiGateway"; +import { DEFAULT_CONTENT_EMBEDDING_MODEL, resolveContentEmbeddingModel } from "./lib/embeddingModels"; import { makeFunctionReference, type FunctionReference } from "convex/server"; import type { Id } from "./_generated/dataModel"; -const DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small"; const RUNTIME_KNOWLEDGE_DEFAULT_LIMIT = 5; const RUNTIME_KNOWLEDGE_MAX_LIMIT = 20; @@ -75,7 +75,9 @@ export const getRelevantKnowledgeForRuntimeAction = internalAction({ const aiClient = createAIClient(); const runQuery = getShallowRunQuery(ctx); - const embeddingModel = args.embeddingModel?.trim() || DEFAULT_EMBEDDING_MODEL; + const embeddingModel = resolveContentEmbeddingModel( + args.embeddingModel?.trim() || DEFAULT_CONTENT_EMBEDDING_MODEL + ); // 1. Embed the query const { embedding } = await embed({ diff --git a/packages/convex/convex/embeddings.ts b/packages/convex/convex/embeddings.ts index fc812af..be1d123 100644 --- a/packages/convex/convex/embeddings.ts +++ b/packages/convex/convex/embeddings.ts @@ -5,13 +5,13 @@ import { Doc, Id } from "./_generated/dataModel"; import { embedMany } from "ai"; import { authAction } from "./lib/authWrappers"; import { createAIClient } from "./lib/aiGateway"; +import { DEFAULT_CONTENT_EMBEDDING_MODEL, resolveContentEmbeddingModel } from "./lib/embeddingModels"; import { isInternalArticle, isPublicArticle, listUnifiedArticlesWithLegacyFallback, } from "./lib/unifiedArticles"; -const DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small"; const EMBEDDING_GENERATE_CONCURRENCY = 4; const EMBEDDING_BACKFILL_BATCH_CONCURRENCY = 2; @@ -315,7 +315,9 @@ export const generateInternal = internalAction({ return { id: existing[0]._id, skipped: true }; } - const modelName = args.model || DEFAULT_EMBEDDING_MODEL; + const modelName = resolveContentEmbeddingModel( + args.model?.trim() || DEFAULT_CONTENT_EMBEDDING_MODEL + ); const aiClient = createAIClient(); const chunkTexts = chunks.map((chunk) => `${args.title}\n\n${chunk}`); const { embeddings } = await embedMany({ @@ -439,7 +441,7 @@ export const backfillExisting = authAction({ batchSize: v.optional(v.number()), model: v.optional(v.string()), }, - permission: "articles.read", + permission: "articles.publish", handler: async (ctx, args) => { const contentTypes = args.contentTypes || ["article", "internalArticle", "snippet"]; const batchSize = args.batchSize || 50; diff --git a/packages/convex/convex/knowledge.ts b/packages/convex/convex/knowledge.ts index 6ffeaaa..54ed7f4 100644 --- a/packages/convex/convex/knowledge.ts +++ b/packages/convex/convex/knowledge.ts @@ -1,7 +1,11 @@ import { v } from "convex/values"; -import { authMutation, authQuery } from "./lib/authWrappers"; +import { type FunctionReference } from "convex/server"; +import { embed } from "ai"; +import { internal } from "./_generated/api"; +import { authAction, authMutation, authQuery } from "./lib/authWrappers"; import { Id } from "./_generated/dataModel"; import type { MutationCtx, QueryCtx } from "./_generated/server"; +import { createAIClient } from "./lib/aiGateway"; import { getArticleVisibility, getUnifiedArticleByIdOrLegacyInternalId, @@ -232,6 +236,129 @@ export const search = authQuery({ }, }); +type EmbeddingQueryRef, Return> = FunctionReference< + "query", + "internal", + Args, + Return +>; + +// NOTE: getShallowRunQuery uses a type escape cast to work around TS2589 +// (Type instantiation is excessively deep) when calling ctx.runQuery with +// generated internal refs. The cast keeps the call signature shallow at the +// hotspot. This can be removed once TypeScript or Convex provides better +// type inference for cross-function calls in actions. +function getShallowRunQuery(ctx: { runQuery: unknown }) { + return ctx.runQuery as unknown as , Return>( + queryRef: EmbeddingQueryRef, + queryArgs: Args + ) => Promise; +} + +const DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small"; +const KNOWLEDGE_SEARCH_DEFAULT_LIMIT = 20; +const KNOWLEDGE_SEARCH_MAX_LIMIT = 50; + +export const searchWithEmbeddings = authAction({ + args: { + workspaceId: v.id("workspaces"), + query: v.string(), + contentTypes: v.optional(v.array(contentTypeValidator)), + limit: v.optional(v.number()), + }, + permission: "articles.read", + handler: async (ctx, args): Promise => { + const limit = Math.max( + 1, + Math.min(args.limit ?? KNOWLEDGE_SEARCH_DEFAULT_LIMIT, KNOWLEDGE_SEARCH_MAX_LIMIT) + ); + + const aiClient = createAIClient(); + const runQuery = getShallowRunQuery(ctx); + + const { embedding } = await embed({ + model: aiClient.embedding(DEFAULT_EMBEDDING_MODEL), + value: args.query, + }); + + const vectorResults = await ctx.vectorSearch("contentEmbeddings", "by_embedding", { + vector: embedding, + limit: limit * 8, + filter: (q) => q.eq("workspaceId", args.workspaceId), + }); + + const contentTypeSet = + args.contentTypes && args.contentTypes.length > 0 ? new Set(args.contentTypes) : null; + + const embeddingDocs = await Promise.all( + vectorResults.map((result) => + runQuery(internal.suggestions.getEmbeddingById, { id: result._id }).then((doc) => + doc ? { ...doc, _score: result._score } : null + ) + ) + ); + + const seen = new Set(); + const dedupedEmbeddingDocs: { + contentId: string; + contentType: KnowledgeContentType; + title: string; + snippet: string; + updatedAt: number; + _score: number; + }[] = []; + + for (const doc of embeddingDocs) { + if (!doc) continue; + if (contentTypeSet && !contentTypeSet.has(doc.contentType)) continue; + + const key = `${doc.contentType}:${doc.contentId}`; + if (seen.has(key)) continue; + seen.add(key); + + dedupedEmbeddingDocs.push({ + contentId: doc.contentId, + contentType: doc.contentType, + title: doc.title, + snippet: doc.snippet, + updatedAt: doc.updatedAt, + _score: doc._score, + }); + + if (dedupedEmbeddingDocs.length >= limit) break; + } + + const contentRecords = await Promise.all( + dedupedEmbeddingDocs.map((doc) => + runQuery(internal.suggestions.getContentById, { + contentType: doc.contentType, + contentId: doc.contentId, + }) + ) + ); + + const results: KnowledgeSearchResult[] = []; + for (let i = 0; i < dedupedEmbeddingDocs.length; i++) { + const doc = dedupedEmbeddingDocs[i]; + const content = contentRecords[i]; + if (!doc || !content) continue; + + results.push({ + id: doc.contentId, + type: doc.contentType, + title: doc.title, + content: content.content, + snippet: doc.snippet, + slug: content.slug, + relevanceScore: doc._score, + updatedAt: doc.updatedAt, + }); + } + + return results; + }, +}); + export const trackAccess = authMutation({ args: { userId: v.id("users"), @@ -316,7 +443,9 @@ export const getRecentlyUsed = authQuery({ accessedAt: number; }> = []; - for (const record of accessRecords.sort((a, b) => b.accessedAt - a.accessedAt).slice(0, limit)) { + for (const record of accessRecords + .sort((a, b) => b.accessedAt - a.accessedAt) + .slice(0, limit)) { if (record.contentType === "snippet") { const snippet = await ctx.db.get(record.contentId as Id<"snippets">); if (!snippet) { @@ -425,7 +554,12 @@ export const getFrequentlyUsed = authQuery({ continue; } - const article = await resolveArticleKnowledgeItem(ctx, args.workspaceId, data.type, contentId); + const article = await resolveArticleKnowledgeItem( + ctx, + args.workspaceId, + data.type, + contentId + ); if (!article) { continue; } diff --git a/packages/convex/convex/lib/aiGateway.ts b/packages/convex/convex/lib/aiGateway.ts index 829254e..747029b 100644 --- a/packages/convex/convex/lib/aiGateway.ts +++ b/packages/convex/convex/lib/aiGateway.ts @@ -1,17 +1,74 @@ import { createOpenAI } from "@ai-sdk/openai"; +const DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1"; +const DEFAULT_VERCEL_AI_GATEWAY_BASE_URL = "https://ai-gateway.vercel.sh/v1"; +const GENERIC_PROVIDER_LABEL = "gateway"; + +function sanitizeProviderLabel(value: string): string { + const sanitized = value.trim().toLowerCase().replace(/[^a-z0-9]+/g, ""); + return sanitized || GENERIC_PROVIDER_LABEL; +} + +export function getAIGatewayApiKey(): string | undefined { + const apiKey = process.env.AI_GATEWAY_API_KEY?.trim(); + return apiKey && apiKey.length > 0 ? apiKey : undefined; +} + +export function getAIBaseURL(apiKey = getAIGatewayApiKey()): string { + const configuredBaseURL = process.env.AI_GATEWAY_BASE_URL?.trim(); + if (configuredBaseURL && configuredBaseURL.length > 0) { + return configuredBaseURL.replace(/\/+$/, ""); + } + + return apiKey?.startsWith("vck_") + ? DEFAULT_VERCEL_AI_GATEWAY_BASE_URL + : DEFAULT_OPENAI_BASE_URL; +} + +export function getAIGatewayProviderLabel(baseURL = getAIBaseURL()): string { + try { + const hostname = new URL(baseURL).hostname.toLowerCase(); + if (hostname === "api.openai.com" || hostname.endsWith(".openai.com")) { + return "openai"; + } + if (hostname === "ai-gateway.vercel.sh") { + return GENERIC_PROVIDER_LABEL; + } + + const segments = hostname + .split(".") + .filter(Boolean) + .filter((segment) => !["api", "chat", "www", "gateway"].includes(segment)); + + if (segments.length >= 2 && segments[segments.length - 1] === "ai") { + const providerRoot = segments[segments.length - 2] ?? ""; + if (providerRoot.length === 1) { + return sanitizeProviderLabel(`${providerRoot}ai`); + } + } + + for (let index = segments.length - 1; index >= 0; index -= 1) { + const segment = segments[index]; + if (!segment || ["com", "ai", "net", "dev", "app", "io", "sh", "co"].includes(segment)) { + continue; + } + return sanitizeProviderLabel(segment); + } + } catch { + return GENERIC_PROVIDER_LABEL; + } + + return GENERIC_PROVIDER_LABEL; +} + export function createAIClient() { - const apiKey = process.env.AI_GATEWAY_API_KEY; + const apiKey = getAIGatewayApiKey(); if (!apiKey) { throw new Error("AI_GATEWAY_API_KEY environment variable is not set"); } - const baseURL = - process.env.AI_GATEWAY_BASE_URL || - (apiKey.startsWith("vck_") ? "https://ai-gateway.vercel.sh/v1" : "https://api.openai.com/v1"); - return createOpenAI({ apiKey, - baseURL, + baseURL: getAIBaseURL(apiKey), }); } diff --git a/packages/convex/convex/lib/embeddingModels.ts b/packages/convex/convex/lib/embeddingModels.ts new file mode 100644 index 0000000..83ee9a9 --- /dev/null +++ b/packages/convex/convex/lib/embeddingModels.ts @@ -0,0 +1,45 @@ +export const CONTENT_EMBEDDING_INDEX_DIMENSIONS = 1536; +export const DEFAULT_CONTENT_EMBEDDING_MODEL = "text-embedding-3-small"; +export const LARGE_CONTENT_EMBEDDING_MODEL = "text-embedding-3-large"; +export const LEGACY_CONTENT_EMBEDDING_MODEL = "text-embedding-ada-002"; + +function normalizeModelName(model: string | undefined): string { + return model?.trim().toLowerCase() ?? ""; +} + +export function getDefaultContentEmbeddingModel(): string { + return DEFAULT_CONTENT_EMBEDDING_MODEL; +} + +export function getContentEmbeddingIndexDimensions(): number { + return CONTENT_EMBEDDING_INDEX_DIMENSIONS; +} + +export function isContentEmbeddingModelSupportedByCurrentIndex(model: string | undefined): boolean { + const normalized = normalizeModelName(model); + return ( + normalized === "" || + normalized === DEFAULT_CONTENT_EMBEDDING_MODEL || + normalized === LEGACY_CONTENT_EMBEDDING_MODEL + ); +} + +export function resolveContentEmbeddingModel(model: string | undefined): string { + const normalized = normalizeModelName(model); + if (normalized === "" || normalized === DEFAULT_CONTENT_EMBEDDING_MODEL) { + return DEFAULT_CONTENT_EMBEDDING_MODEL; + } + if (normalized === LEGACY_CONTENT_EMBEDDING_MODEL) { + return LEGACY_CONTENT_EMBEDDING_MODEL; + } + return DEFAULT_CONTENT_EMBEDDING_MODEL; +} + +export function getContentEmbeddingModelCompatibilityMessage(model: string | undefined): string | null { + const normalized = normalizeModelName(model); + if (normalized === LARGE_CONTENT_EMBEDDING_MODEL) { + return `The current content embedding index expects ${CONTENT_EMBEDDING_INDEX_DIMENSIONS}-dimension vectors, so ${LARGE_CONTENT_EMBEDDING_MODEL} is not compatible yet. Falling back to ${DEFAULT_CONTENT_EMBEDDING_MODEL}.`; + } + + return null; +} diff --git a/packages/convex/convex/schema/inboxConversationTables.ts b/packages/convex/convex/schema/inboxConversationTables.ts index 26a6074..9e13b5d 100644 --- a/packages/convex/convex/schema/inboxConversationTables.ts +++ b/packages/convex/convex/schema/inboxConversationTables.ts @@ -87,6 +87,7 @@ export const inboxConversationTables = { aiWorkflowState: v.optional( v.union(v.literal("none"), v.literal("ai_handled"), v.literal("handoff")) ), + aiTurnSequence: v.optional(v.number()), aiHandoffReason: v.optional(v.string()), aiLastConfidence: v.optional(v.number()), aiLastResponseAt: v.optional(v.number()), diff --git a/packages/convex/convex/schema/operationsAiTables.ts b/packages/convex/convex/schema/operationsAiTables.ts index eb2c20a..0795c7a 100644 --- a/packages/convex/convex/schema/operationsAiTables.ts +++ b/packages/convex/convex/schema/operationsAiTables.ts @@ -1,5 +1,6 @@ import { defineTable } from "convex/server"; import { v } from "convex/values"; +import { CONTENT_EMBEDDING_INDEX_DIMENSIONS } from "../lib/embeddingModels"; export const operationsAiTables = { aiAgentSettings: defineTable({ @@ -49,7 +50,7 @@ export const operationsAiTables = { .index("by_workspace", ["workspaceId"]) .vectorIndex("by_embedding", { vectorField: "embedding", - dimensions: 1536, + dimensions: CONTENT_EMBEDDING_INDEX_DIMENSIONS, filterFields: ["workspaceId", "contentType"], }), @@ -59,6 +60,7 @@ export const operationsAiTables = { conversationId: v.id("conversations"), contentType: v.union(v.literal("article"), v.literal("internalArticle"), v.literal("snippet")), contentId: v.string(), + embeddingModel: v.optional(v.string()), action: v.union(v.literal("used"), v.literal("dismissed")), createdAt: v.number(), }) diff --git a/packages/convex/convex/schema/operationsReportingTables.ts b/packages/convex/convex/schema/operationsReportingTables.ts index 5f028bc..6a31a71 100644 --- a/packages/convex/convex/schema/operationsReportingTables.ts +++ b/packages/convex/convex/schema/operationsReportingTables.ts @@ -5,7 +5,7 @@ import { jsonRecordValidator } from "../validators"; export const operationsReportingTables = { aiResponses: defineTable({ conversationId: v.id("conversations"), - messageId: v.id("messages"), + messageId: v.optional(v.id("messages")), query: v.string(), response: v.string(), generatedCandidateResponse: v.optional(v.string()), @@ -28,6 +28,7 @@ export const operationsReportingTables = { articleId: v.optional(v.string()), }) ), + attemptStatus: v.optional(v.string()), confidence: v.number(), feedback: v.optional(v.union(v.literal("helpful"), v.literal("not_helpful"))), handedOff: v.boolean(), diff --git a/packages/convex/convex/suggestions.ts b/packages/convex/convex/suggestions.ts index a40065b..b36bb9f 100644 --- a/packages/convex/convex/suggestions.ts +++ b/packages/convex/convex/suggestions.ts @@ -5,10 +5,10 @@ import { Doc, Id } from "./_generated/dataModel"; import { embed } from "ai"; import { authAction, authMutation, authQuery } from "./lib/authWrappers"; import { createAIClient } from "./lib/aiGateway"; +import { DEFAULT_CONTENT_EMBEDDING_MODEL, resolveContentEmbeddingModel } from "./lib/embeddingModels"; import { getUnifiedArticleByIdOrLegacyInternalId, isInternalArticle } from "./lib/unifiedArticles"; import type { Permission } from "./permissions"; -const DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small"; const FEEDBACK_STATS_DEFAULT_LIMIT = 5000; const FEEDBACK_STATS_MAX_LIMIT = 20000; const SUGGESTIONS_DEFAULT_LIMIT = 10; @@ -41,6 +41,7 @@ type SuggestionResult = { title: string; snippet: string; score: number; + embeddingModel?: string; }; type SuggestionResultWithContent = SuggestionResult & { content: string }; @@ -92,9 +93,11 @@ type OriginValidationResult = { const GET_EMBEDDING_BY_ID_REF: SuggestionQueryRef< { id: Id<"contentEmbeddings"> }, Doc<"contentEmbeddings"> | null -> = makeFunctionReference<"query", { id: Id<"contentEmbeddings"> }, Doc<"contentEmbeddings"> | null>( - "suggestions:getEmbeddingById" -); +> = makeFunctionReference< + "query", + { id: Id<"contentEmbeddings"> }, + Doc<"contentEmbeddings"> | null +>("suggestions:getEmbeddingById"); const GET_CONVERSATION_REF: SuggestionQueryRef< { conversationId: Id<"conversations"> }, @@ -125,9 +128,11 @@ const REQUIRE_PERMISSION_FOR_ACTION_REF: SuggestionQueryRef< const GET_AI_SETTINGS_REF: SuggestionQueryRef< { workspaceId: Id<"workspaces"> }, Doc<"aiAgentSettings"> | null -> = makeFunctionReference<"query", { workspaceId: Id<"workspaces"> }, Doc<"aiAgentSettings"> | null>( - "suggestions:getAiSettings" -); +> = makeFunctionReference< + "query", + { workspaceId: Id<"workspaces"> }, + Doc<"aiAgentSettings"> | null +>("suggestions:getAiSettings"); const GET_RECENT_MESSAGES_REF: SuggestionQueryRef< { conversationId: Id<"conversations">; limit: number }, @@ -228,7 +233,11 @@ function normalizeSuggestionContentTypes( } const normalized = Array.from( - new Set(values.map(normalizeSuggestionContentType).filter((value): value is SuggestionContentType => Boolean(value))) + new Set( + values + .map(normalizeSuggestionContentType) + .filter((value): value is SuggestionContentType => Boolean(value)) + ) ); return normalized.length > 0 ? normalized : undefined; @@ -250,7 +259,9 @@ export const searchSimilar = authAction({ 1, Math.min(args.limit ?? SUGGESTIONS_DEFAULT_LIMIT, SUGGESTIONS_MAX_LIMIT) ); - const modelName = args.model || DEFAULT_EMBEDDING_MODEL; + const modelName = resolveContentEmbeddingModel( + args.model?.trim() || DEFAULT_CONTENT_EMBEDDING_MODEL + ); const aiClient = createAIClient(); const runQuery = getShallowRunQuery(ctx); @@ -342,6 +353,8 @@ export const getForConversation = authAction({ return []; } + const embeddingModel = resolveContentEmbeddingModel(settings.embeddingModel); + const messages = await runQuery(GET_RECENT_MESSAGES_REF, { conversationId: args.conversationId, limit: 5, @@ -361,10 +374,13 @@ export const getForConversation = authAction({ query: contextText, contentTypes: normalizedContentTypes, limit, - model: settings.embeddingModel, + model: embeddingModel, }); - return results; + return results.map((result) => ({ + ...result, + embeddingModel, + })); }, }); @@ -395,7 +411,9 @@ export const searchSimilarInternal = internalAction({ 1, Math.min(args.limit ?? SUGGESTIONS_DEFAULT_LIMIT, SUGGESTIONS_MAX_LIMIT) ); - const modelName = args.model || DEFAULT_EMBEDDING_MODEL; + const modelName = resolveContentEmbeddingModel( + args.model?.trim() || DEFAULT_CONTENT_EMBEDDING_MODEL + ); const aiClient = createAIClient(); const runQuery = getShallowRunQuery(ctx); @@ -476,6 +494,7 @@ export const trackUsage = authMutation({ conversationId: v.id("conversations"), contentType: v.union(v.literal("article"), v.literal("internalArticle"), v.literal("snippet")), contentId: v.string(), + embeddingModel: v.optional(v.string()), }, permission: "conversations.read", handler: async (ctx, args) => { @@ -493,6 +512,7 @@ export const trackUsage = authMutation({ conversationId: args.conversationId, contentType: args.contentType, contentId: args.contentId, + embeddingModel: args.embeddingModel, action: "used", createdAt: Date.now(), }); @@ -505,6 +525,7 @@ export const trackDismissal = authMutation({ conversationId: v.id("conversations"), contentType: v.union(v.literal("article"), v.literal("internalArticle"), v.literal("snippet")), contentId: v.string(), + embeddingModel: v.optional(v.string()), }, permission: "conversations.read", handler: async (ctx, args) => { @@ -522,6 +543,7 @@ export const trackDismissal = authMutation({ conversationId: args.conversationId, contentType: args.contentType, contentId: args.contentId, + embeddingModel: args.embeddingModel, action: "dismissed", createdAt: Date.now(), }); @@ -628,7 +650,7 @@ export const getContentById = internalQuery({ args.contentId as Id<"articles"> | Id<"internalArticles"> ); if (article && article.visibility !== "internal") { - return { content: article.content, title: article.title }; + return { content: article.content, title: article.title, slug: article.slug }; } } else if (args.contentType === "internalArticle") { const article = await getUnifiedArticleByIdOrLegacyInternalId( @@ -707,7 +729,7 @@ export const searchForWidget = action({ const aiClient = createAIClient(); const { embedding } = await embed({ - model: aiClient.embedding(DEFAULT_EMBEDDING_MODEL), + model: aiClient.embedding(DEFAULT_CONTENT_EMBEDDING_MODEL), value: normalizedQuery, }); diff --git a/packages/convex/convex/testAdmin.ts b/packages/convex/convex/testAdmin.ts index abbeaef..e30a000 100644 --- a/packages/convex/convex/testAdmin.ts +++ b/packages/convex/convex/testAdmin.ts @@ -38,6 +38,25 @@ function getShallowRunMutation(ctx: { runMutation: unknown }) { ) => Promise; } +function parseMutationArgsJson(mutationArgsJson: string): Record { + if (!mutationArgsJson.trim()) { + return {}; + } + + let parsed: unknown; + try { + parsed = JSON.parse(mutationArgsJson); + } catch { + throw new Error("mutationArgsJson must be valid JSON."); + } + + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("mutationArgsJson must decode to a JSON object."); + } + + return parsed as Record; +} + export function isAuthorizedAdminSecret(providedSecret: string, expectedSecret: string): boolean { const providedBytes = toSecretBytes(providedSecret); const expectedBytes = toSecretBytes(expectedSecret); @@ -62,9 +81,9 @@ export const runTestMutation = action({ args: { secret: v.string(), name: v.string(), - mutationArgs: v.any(), + mutationArgsJson: v.string(), }, - handler: async (ctx, { secret, name, mutationArgs }) => { + handler: async (ctx, { secret, name, mutationArgsJson }) => { // Validate admin secret const expected = process.env.TEST_ADMIN_SECRET; if (!expected) { @@ -92,6 +111,6 @@ export const runTestMutation = action({ } const runMutation = getShallowRunMutation(ctx); - return await runMutation(getInternalRef(name), (mutationArgs ?? {}) as Record); + return await runMutation(getInternalRef(name), parseMutationArgsJson(mutationArgsJson)); }, }); diff --git a/packages/convex/convex/testing/helpers.ts b/packages/convex/convex/testing/helpers.ts index 060308e..12c7ae8 100644 --- a/packages/convex/convex/testing/helpers.ts +++ b/packages/convex/convex/testing/helpers.ts @@ -77,6 +77,8 @@ export const updateTestAllowedOrigins: ReturnType = wor export const updateTestSignupSettings: ReturnType = workspaceTestHelpers.updateTestSignupSettings; export const getTestAISettings: ReturnType = aiTestHelpers.getTestAISettings; export const updateTestAISettings: ReturnType = aiTestHelpers.updateTestAISettings; +export const listTestSuggestionFeedback: ReturnType = + aiTestHelpers.listTestSuggestionFeedback; export const getTestVisitor: ReturnType = conversationTestHelpers.getTestVisitor; export const addTestWorkspaceMember: ReturnType = workspaceTestHelpers.addTestWorkspaceMember; export const listTestWorkspaceMembers: ReturnType = workspaceTestHelpers.listTestWorkspaceMembers; diff --git a/packages/convex/convex/testing/helpers/ai.ts b/packages/convex/convex/testing/helpers/ai.ts index 4c1b03a..1d62fe2 100644 --- a/packages/convex/convex/testing/helpers/ai.ts +++ b/packages/convex/convex/testing/helpers/ai.ts @@ -173,6 +173,19 @@ const updateTestAISettings = internalMutation({ }, }); +const listTestSuggestionFeedback = internalMutation({ + args: { + workspaceId: v.id("workspaces"), + }, + handler: async (ctx, args) => { + return await ctx.db + .query("suggestionFeedback") + .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId)) + .order("desc") + .collect(); + }, +}); + /** * Gets a visitor by ID directly (bypasses auth). */ @@ -181,4 +194,5 @@ export const aiTestHelpers: Record> seedTestAIResponse, getTestAISettings, updateTestAISettings, + listTestSuggestionFeedback, } as const; diff --git a/packages/convex/package.json b/packages/convex/package.json index 0af72e6..d7e15b5 100644 --- a/packages/convex/package.json +++ b/packages/convex/package.json @@ -31,7 +31,7 @@ "@convex-dev/auth": "^0.0.90", "@opencom/types": "workspace:*", "ai": "^6.0.48", - "convex": "^1.32.0", + "convex": "1.35.1", "convex-test": "^0.0.41", "jose": "^6.1.3", "oslo": "^1.2.1", diff --git a/packages/convex/tests/aiAgent.test.ts b/packages/convex/tests/aiAgent.test.ts index 1902f86..cbf375f 100644 --- a/packages/convex/tests/aiAgent.test.ts +++ b/packages/convex/tests/aiAgent.test.ts @@ -174,6 +174,8 @@ describe("aiAgent", () => { expect(responses.length).toBeGreaterThan(0); expect(responses[0].query).toBe("How do I reset my password?"); expect(responses[0].confidence).toBe(0.85); + expect(responses[0].model).toBe("openai/gpt-5-nano"); + expect(responses[0].provider).toBe("openai"); }); }); @@ -274,12 +276,24 @@ describe("aiAgent", () => { describe("listAvailableModels", () => { it("should return list of available models", async () => { - const models = await client.query(api.aiAgent.listAvailableModels, {}); + const models = await client.action(api.aiAgent.listAvailableModels, { + workspaceId: testWorkspaceId, + selectedModel: "openai/gpt-5-nano", + }); expect(models.length).toBeGreaterThan(0); expect(models[0]).toHaveProperty("id"); expect(models[0]).toHaveProperty("name"); expect(models[0]).toHaveProperty("provider"); }); + + it("should preserve the selected model when discovery falls back", async () => { + const models = await client.action(api.aiAgent.listAvailableModels, { + workspaceId: testWorkspaceId, + selectedModel: "openai/gpt-5.1-mini", + }); + + expect(models.some((model) => model.id === "openai/gpt-5.1-mini")).toBe(true); + }); }); }); diff --git a/packages/convex/tests/aiAgentAuthorizationSemantics.test.ts b/packages/convex/tests/aiAgentAuthorizationSemantics.test.ts index 3d64608..e5d1370 100644 --- a/packages/convex/tests/aiAgentAuthorizationSemantics.test.ts +++ b/packages/convex/tests/aiAgentAuthorizationSemantics.test.ts @@ -22,11 +22,14 @@ vi.mock("../convex/widgetSessions", () => ({ import { getAuthenticatedUserFromSession } from "../convex/auth"; import { requirePermission } from "../convex/permissions"; import { resolveVisitorFromSession } from "../convex/widgetSessions"; -import { getConversationResponses } from "../convex/aiAgent"; +import { getConversationResponses as getConversationResponsesDefinition } from "../convex/aiAgent"; const mockGetAuthenticatedUserFromSession = vi.mocked(getAuthenticatedUserFromSession); const mockRequirePermission = vi.mocked(requirePermission); const mockResolveVisitorFromSession = vi.mocked(resolveVisitorFromSession); +const getConversationResponses = getConversationResponsesDefinition as unknown as { + _handler: (ctx: unknown, args: Record) => Promise>>; +}; describe("aiAgent authorization semantics", () => { beforeEach(() => { @@ -67,14 +70,26 @@ describe("aiAgent authorization semantics", () => { { _id: "ai_response_1", conversationId, + messageId: "message_auth_1", + query: "How can I reset my password?", response: "Response 1", + sources: [], confidence: 0.8, + handedOff: false, + model: "openai/gpt-5-nano", + provider: "openai", }, { _id: "ai_response_2", conversationId, + messageId: "message_auth_2", + query: "Can you help with billing?", response: "Response 2", + sources: [], confidence: 0.7, + handedOff: false, + model: "anthropic/claude-3-5-haiku-latest", + provider: "anthropic", }, ]; @@ -118,6 +133,10 @@ describe("aiAgent authorization semantics", () => { expect(result).toHaveLength(2); expect(result[0].conversationId).toBe(conversationId); + expect(result[0].model).toBe("openai/gpt-5-nano"); + expect(result[0].provider).toBe("openai"); + expect(result[1].model).toBe("anthropic/claude-3-5-haiku-latest"); + expect(result[1].provider).toBe("anthropic"); }); it("exposes delivered and generated contexts with legacy compatibility", async () => { diff --git a/packages/convex/tests/aiAgentRuntimeSafety.test.ts b/packages/convex/tests/aiAgentRuntimeSafety.test.ts index e16aad3..ef98aaf 100644 --- a/packages/convex/tests/aiAgentRuntimeSafety.test.ts +++ b/packages/convex/tests/aiAgentRuntimeSafety.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("ai", () => ({ generateText: vi.fn(), @@ -11,29 +11,133 @@ vi.mock("@ai-sdk/openai", () => ({ })); import { generateText } from "ai"; -import { generateResponse, getAIConfigurationDiagnostic } from "../convex/aiAgentActions"; +import { listAvailableModels as listAvailableModelsDefinition } from "../convex/aiAgent"; +import { + generateResponse as generateResponseDefinition, + getAIConfigurationDiagnostic, +} from "../convex/aiAgentActions"; const mockGenerateText = vi.mocked(generateText); +type GenerateResponseHandlerResult = { + response: string; + confidence: number; + sources: Array>; + handoff: boolean; + handoffReason: string | null; + messageId: string | null; +}; +const generateResponse = generateResponseDefinition as unknown as { + _handler: (ctx: unknown, args: Record) => Promise; +}; +const generateResponseHandler = generateResponse; +const listAvailableModels = listAvailableModelsDefinition as unknown as { + _handler: ( + ctx: unknown, + args: Record + ) => Promise>; +}; +const listAvailableModelsHandler = listAvailableModels; describe("aiAgentActions runtime safety", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + beforeEach(() => { vi.clearAllMocks(); process.env.AI_GATEWAY_API_KEY = "test-ai-key"; + process.env.AI_GATEWAY_BASE_URL = "https://api.openai.com/v1"; }); it("returns explicit diagnostics for invalid runtime configuration", () => { expect(getAIConfigurationDiagnostic("")).toMatchObject({ code: "MISSING_MODEL", }); - expect(getAIConfigurationDiagnostic("invalid-model-format")).toMatchObject({ + expect(getAIConfigurationDiagnostic("invalid/model/format")).toMatchObject({ code: "INVALID_MODEL_FORMAT", }); - expect(getAIConfigurationDiagnostic("anthropic/claude-3-5-sonnet")).toMatchObject({ - code: "UNSUPPORTED_PROVIDER", - provider: "anthropic", + expect( + getAIConfigurationDiagnostic("zai/glm-5-turbo", { + aiGatewayApiKey: "test-ai-key", + aiGatewayProviderLabel: "zai", + }) + ).toBeNull(); + expect( + getAIConfigurationDiagnostic("glm-5-turbo", { + aiGatewayApiKey: "test-ai-key", + aiGatewayProviderLabel: "zai", + }) + ).toBeNull(); + expect(getAIConfigurationDiagnostic("anthropic/claude-3-5-sonnet")).toBeNull(); + }); + + it("uses the provided gateway provider label for raw-model diagnostics", () => { + expect( + getAIConfigurationDiagnostic("glm-5-turbo", { + aiGatewayApiKey: undefined, + aiGatewayProviderLabel: "zai", + }) + ).toMatchObject({ + code: "MISSING_PROVIDER_CREDENTIALS", + provider: "zai", + model: "glm-5-turbo", }); }); + it("filters provider-prefixed non-generation models and labels raw fallback models correctly", async () => { + const fetchMock = vi.fn(async () => ({ + ok: true, + json: async () => ({ + data: [ + { id: "openai/text-embedding-3-small", created: 30 }, + { id: "openai/gpt-5-nano", created: 20 }, + { id: "gpt-5.1-mini", created: 10 }, + ], + }), + })); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + const runQuery = vi.fn(async (_reference: unknown, args: Record) => { + if (Object.keys(args).length === 0) { + return { + user: { + _id: "user_1", + }, + }; + } + if ("permission" in args) { + return null; + } + throw new Error(`Unexpected query args: ${JSON.stringify(args)}`); + }); + + const result = await listAvailableModelsHandler._handler( + { + runQuery, + } as any, + { + workspaceId: "workspace_1" as any, + selectedModel: "custom-raw-model", + } + ); + + expect(result.some((model) => model.id === "openai/text-embedding-3-small")).toBe(false); + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "openai/gpt-5-nano", + name: "gpt-5-nano", + provider: "openai", + }), + expect.objectContaining({ + id: "custom-raw-model", + name: "custom-raw-model", + provider: "openai", + }), + ]) + ); + }); + it("falls back to handoff and records diagnostics when configuration is invalid", async () => { const runQuery = vi.fn(async (_reference: unknown, args: Record) => { if ("query" in args) { @@ -42,7 +146,7 @@ describe("aiAgentActions runtime safety", () => { if ("workspaceId" in args && "conversationId" in args === false) { return { enabled: true, - model: "invalid-model-format", + model: "invalid/model/format", confidenceThreshold: 0.6, knowledgeSources: ["articles"], personality: null, @@ -68,7 +172,7 @@ describe("aiAgentActions runtime safety", () => { throw new Error(`Unexpected mutation args: ${JSON.stringify(args)}`); }); - const result = await generateResponse._handler( + const result = await generateResponseHandler._handler( { runQuery, runMutation, @@ -121,7 +225,7 @@ describe("aiAgentActions runtime safety", () => { throw new Error(`Unexpected mutation args: ${JSON.stringify(args)}`); }); - const result = await generateResponse._handler( + const result = await generateResponseHandler._handler( { runQuery, runMutation, @@ -151,7 +255,7 @@ describe("aiAgentActions runtime safety", () => { const runMutation = vi.fn(); - const result = await generateResponse._handler( + const result = await generateResponseHandler._handler( { runQuery, runMutation, @@ -407,7 +511,7 @@ describe("aiAgentActions runtime safety", () => { throw new Error(`Unexpected mutation args: ${JSON.stringify(args)}`); }); - await generateResponse._handler( + await generateResponseHandler._handler( { runQuery, runMutation, @@ -852,7 +956,7 @@ describe("aiAgentActions runtime safety", () => { const runMutation = vi.fn(); await expect( - generateResponse._handler( + generateResponseHandler._handler( { runQuery, runMutation, diff --git a/packages/convex/tests/embeddingModels.test.ts b/packages/convex/tests/embeddingModels.test.ts new file mode 100644 index 0000000..df6f7fa --- /dev/null +++ b/packages/convex/tests/embeddingModels.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; + +import { + CONTENT_EMBEDDING_INDEX_DIMENSIONS, + DEFAULT_CONTENT_EMBEDDING_MODEL, + getContentEmbeddingModelCompatibilityMessage, + isContentEmbeddingModelSupportedByCurrentIndex, + LEGACY_CONTENT_EMBEDDING_MODEL, + resolveContentEmbeddingModel, +} from "../convex/lib/embeddingModels"; + +describe("content embedding model compatibility", () => { + it("keeps index-compatible models unchanged", () => { + expect(resolveContentEmbeddingModel(DEFAULT_CONTENT_EMBEDDING_MODEL)).toBe( + DEFAULT_CONTENT_EMBEDDING_MODEL + ); + expect(resolveContentEmbeddingModel(LEGACY_CONTENT_EMBEDDING_MODEL)).toBe( + LEGACY_CONTENT_EMBEDDING_MODEL + ); + expect(isContentEmbeddingModelSupportedByCurrentIndex(DEFAULT_CONTENT_EMBEDDING_MODEL)).toBe(true); + expect(isContentEmbeddingModelSupportedByCurrentIndex(LEGACY_CONTENT_EMBEDDING_MODEL)).toBe(true); + }); + + it("falls back from incompatible large embeddings to the default index-compatible model", () => { + expect(resolveContentEmbeddingModel("text-embedding-3-large")).toBe( + DEFAULT_CONTENT_EMBEDDING_MODEL + ); + expect(isContentEmbeddingModelSupportedByCurrentIndex("text-embedding-3-large")).toBe(false); + expect(getContentEmbeddingModelCompatibilityMessage("text-embedding-3-large")).toContain( + String(CONTENT_EMBEDDING_INDEX_DIMENSIONS) + ); + }); + + it("normalizes supported embedding model ids to canonical constants", () => { + expect(resolveContentEmbeddingModel(" Text-Embedding-3-Small ")).toBe( + DEFAULT_CONTENT_EMBEDDING_MODEL + ); + expect(resolveContentEmbeddingModel("TEXT-EMBEDDING-ADA-002")).toBe( + LEGACY_CONTENT_EMBEDDING_MODEL + ); + }); +}); diff --git a/packages/convex/tests/helpers/testHelpers.ts b/packages/convex/tests/helpers/testHelpers.ts index 37d6659..253eff1 100644 --- a/packages/convex/tests/helpers/testHelpers.ts +++ b/packages/convex/tests/helpers/testHelpers.ts @@ -24,7 +24,7 @@ async function callInternalTestMutation( headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: "testAdmin:runTestMutation", - args: { secret, name, mutationArgs }, + args: { secret, name, mutationArgsJson: JSON.stringify(mutationArgs) }, format: "json", }), }); diff --git a/packages/convex/tests/runtimeTypeHardeningGuard.test.ts b/packages/convex/tests/runtimeTypeHardeningGuard.test.ts index 43a78a7..1262033 100644 --- a/packages/convex/tests/runtimeTypeHardeningGuard.test.ts +++ b/packages/convex/tests/runtimeTypeHardeningGuard.test.ts @@ -9,6 +9,7 @@ const TARGET_FILES = [ "../convex/emailChannel.ts", "../convex/events.ts", "../convex/http.ts", + "../convex/knowledge.ts", "../convex/push.ts", "../convex/pushCampaigns.ts", "../convex/series/runtime.ts", @@ -181,6 +182,19 @@ describe("runtime type hardening guards", () => { expect(suggestionsSource).toContain("VALIDATE_SESSION_TOKEN_REF"); }); + it("uses fixed typed refs for knowledge vector search", () => { + const knowledgeSource = readFileSync( + new URL("../convex/knowledge.ts", import.meta.url), + "utf8" + ); + + expect(knowledgeSource).not.toContain("function getQueryRef(name: string)"); + expect(knowledgeSource).toContain("internal.suggestions.getEmbeddingById"); + expect(knowledgeSource).toContain("internal.suggestions.getContentById"); + expect(knowledgeSource).toContain("getShallowRunQuery"); + expect(knowledgeSource).toContain("NOTE: getShallowRunQuery uses a type escape cast"); + }); + it("uses fixed typed refs for support attachment cleanup scheduling", () => { const supportAttachmentsSource = readFileSync( new URL("../convex/supportAttachments.ts", import.meta.url), diff --git a/packages/convex/tests/setupTestAdminFallback.ts b/packages/convex/tests/setupTestAdminFallback.ts index c5a4525..bf19b5e 100644 --- a/packages/convex/tests/setupTestAdminFallback.ts +++ b/packages/convex/tests/setupTestAdminFallback.ts @@ -26,7 +26,7 @@ async function callInternalTestMutation(name: string, mutationArgs: Record { conversationId: testConversationId, contentType: "article", contentId: testArticleId, + embeddingModel: "text-embedding-3-small", }); expect(feedbackId).toBeDefined(); + + const feedback = await client.mutation(api.testing_helpers.listTestSuggestionFeedback, { + workspaceId: testWorkspaceId, + }); + const stored = feedback.find((entry) => entry._id === feedbackId); + expect(stored?.embeddingModel).toBe("text-embedding-3-small"); }); }); @@ -99,9 +106,16 @@ describe("suggestions", () => { conversationId: testConversationId, contentType: "snippet", contentId: testSnippetId, + embeddingModel: "text-embedding-3-small", }); expect(feedbackId).toBeDefined(); + + const feedback = await client.mutation(api.testing_helpers.listTestSuggestionFeedback, { + workspaceId: testWorkspaceId, + }); + const stored = feedback.find((entry) => entry._id === feedbackId); + expect(stored?.embeddingModel).toBe("text-embedding-3-small"); }); }); diff --git a/packages/convex/tests/suggestionsMetadataSemantics.test.ts b/packages/convex/tests/suggestionsMetadataSemantics.test.ts new file mode 100644 index 0000000..e8eb0d0 --- /dev/null +++ b/packages/convex/tests/suggestionsMetadataSemantics.test.ts @@ -0,0 +1,117 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Id } from "../convex/_generated/dataModel"; + +vi.mock("../convex/auth", () => ({ + getAuthenticatedUserFromSession: vi.fn(), +})); + +vi.mock("../convex/permissions", async () => { + const actual = + await vi.importActual("../convex/permissions"); + return { + ...actual, + requirePermission: vi.fn(), + }; +}); + +import { getAuthenticatedUserFromSession } from "../convex/auth"; +import { requirePermission } from "../convex/permissions"; +import { + trackDismissal as trackDismissalDefinition, + trackUsage as trackUsageDefinition, +} from "../convex/suggestions"; + +const mockGetAuthenticatedUserFromSession = vi.mocked(getAuthenticatedUserFromSession); +const mockRequirePermission = vi.mocked(requirePermission); +const trackUsage = trackUsageDefinition as unknown as { + _handler: (ctx: unknown, args: Record) => Promise; +}; +const trackDismissal = trackDismissalDefinition as unknown as { + _handler: (ctx: unknown, args: Record) => Promise; +}; + +function workspaceId(value: string): Id<"workspaces"> { + return value as Id<"workspaces">; +} + +function conversationId(value: string): Id<"conversations"> { + return value as Id<"conversations">; +} + +describe("suggestions metadata semantics", () => { + beforeEach(() => { + vi.resetAllMocks(); + mockGetAuthenticatedUserFromSession.mockResolvedValue({ + _id: "user_meta" as Id<"users">, + } as never); + mockRequirePermission.mockResolvedValue(undefined as never); + }); + + it("persists embeddingModel on suggestion usage feedback", async () => { + const insert = vi.fn(async () => "feedback_usage_1"); + const ctx = { + db: { + get: vi.fn(async () => ({ + _id: conversationId("conversation_usage"), + workspaceId: workspaceId("workspace_usage"), + })), + insert, + }, + }; + + const result = await trackUsage._handler(ctx as never, { + workspaceId: workspaceId("workspace_usage"), + conversationId: conversationId("conversation_usage"), + contentType: "article", + contentId: "article_1", + embeddingModel: "text-embedding-3-small", + }); + + expect(result).toBe("feedback_usage_1"); + expect(insert).toHaveBeenCalledWith( + "suggestionFeedback", + expect.objectContaining({ + workspaceId: workspaceId("workspace_usage"), + conversationId: conversationId("conversation_usage"), + contentType: "article", + contentId: "article_1", + action: "used", + embeddingModel: "text-embedding-3-small", + }) + ); + }); + + it("persists embeddingModel on suggestion dismissal feedback", async () => { + const insert = vi.fn(async () => "feedback_dismissal_1"); + const ctx = { + db: { + get: vi.fn(async () => ({ + _id: conversationId("conversation_dismissal"), + workspaceId: workspaceId("workspace_dismissal"), + })), + insert, + }, + }; + + const result = await trackDismissal._handler(ctx as never, { + workspaceId: workspaceId("workspace_dismissal"), + conversationId: conversationId("conversation_dismissal"), + contentType: "snippet", + contentId: "snippet_1", + embeddingModel: "text-embedding-3-small", + }); + + expect(result).toBe("feedback_dismissal_1"); + expect(insert).toHaveBeenCalledWith( + "suggestionFeedback", + expect.objectContaining({ + workspaceId: workspaceId("workspace_dismissal"), + conversationId: conversationId("conversation_dismissal"), + contentType: "snippet", + contentId: "snippet_1", + action: "dismissed", + embeddingModel: "text-embedding-3-small", + }) + ); + }); +}); diff --git a/packages/react-native-sdk/package.json b/packages/react-native-sdk/package.json index f6aa2ed..ceac304 100644 --- a/packages/react-native-sdk/package.json +++ b/packages/react-native-sdk/package.json @@ -41,7 +41,7 @@ "@opencom/sdk-core": "workspace:*", "@opencom/types": "workspace:*", "@react-native-async-storage/async-storage": "^1.23.0", - "convex": "^1.32.0", + "convex": "1.35.1", "react-native-safe-area-context": "^5.6.2", "react-native-svg": "^15.0.0" }, diff --git a/packages/sdk-core/package.json b/packages/sdk-core/package.json index dcf9415..8a6810e 100644 --- a/packages/sdk-core/package.json +++ b/packages/sdk-core/package.json @@ -21,7 +21,7 @@ }, "dependencies": { "@opencom/types": "workspace:*", - "convex": "^1.32.0" + "convex": "1.35.1" }, "devDependencies": { "tsup": "^8.0.0", diff --git a/packages/web-shared/src/markdown.test.ts b/packages/web-shared/src/markdown.test.ts index 8c52c43..f2d234f 100644 --- a/packages/web-shared/src/markdown.test.ts +++ b/packages/web-shared/src/markdown.test.ts @@ -33,6 +33,31 @@ describe("parseMarkdown", () => { expect(html).not.toContain("target="); expect(html).not.toContain("rel="); }); + + it("detects article links and adds data-article-id attribute", () => { + const html = parseMarkdown("[Read more](article:k57f8d9g2h3j4k5l)"); + expect(html).toContain('data-article-id="k57f8d9g2h3j4k5l"'); + expect(html).toContain('href="article:k57f8d9g2h3j4k5l"'); + expect(html).not.toContain("target="); + expect(html).not.toContain("rel="); + }); + + it("renders article link text correctly", () => { + const html = parseMarkdown("[Help Article](article:abc123)"); + expect(html).toContain(">Help Article<"); + }); + + it("handles invalid article link format gracefully", () => { + const html = parseMarkdown("[Invalid](article:)"); + expect(html).not.toContain("data-article-id"); + expect(html).not.toContain("href="); + }); + + it("does not allow article protocol in image src", () => { + const html = parseMarkdown("![alt](article:abc123)"); + expect(html).not.toContain("article:"); + expect(html).not.toContain('src="article:'); + }); }); describe("frontmatter and excerpt helpers", () => { diff --git a/packages/web-shared/src/markdown.ts b/packages/web-shared/src/markdown.ts index 9335951..8760228 100644 --- a/packages/web-shared/src/markdown.ts +++ b/packages/web-shared/src/markdown.ts @@ -31,7 +31,7 @@ const ALLOWED_TAGS = [ "img", ]; -const ALLOWED_ATTR = ["href", "target", "rel", "src", "alt", "title", "class"]; +const ALLOWED_ATTR = ["href", "target", "rel", "src", "alt", "title", "class", "data-article-id"]; const FRONTMATTER_BLOCK_REGEX = /^\uFEFF?---\s*\r?\n[\s\S]*?\r?\n---(?:\s*\r?\n)?/; type ResolvedParseMarkdownOptions = { @@ -80,16 +80,45 @@ function hasDisallowedAbsoluteProtocol(rawUrl: string): boolean { return protocol !== "http" && protocol !== "https"; } -function enforceSafeLinksAndMedia( - html: string, - options: ResolvedParseMarkdownOptions -): string { +function isArticleLink(href: string): boolean { + return href.trim().toLowerCase().startsWith("article:"); +} + +function extractArticleId(href: string): string | null { + const match = href.trim().match(/^article:([a-zA-Z0-9]+)$/i); + return match ? match[1] : null; +} + +function enforceSafeLinksAndMedia(html: string, options: ResolvedParseMarkdownOptions): string { const container = document.createElement("div"); container.innerHTML = html; container.querySelectorAll("a").forEach((anchor) => { const href = anchor.getAttribute("href"); - if (!href || hasBlockedProtocol(href) || hasDisallowedAbsoluteProtocol(href)) { + if (!href || hasBlockedProtocol(href)) { + anchor.removeAttribute("href"); + anchor.removeAttribute("target"); + anchor.removeAttribute("rel"); + return; + } + + if (isArticleLink(href)) { + const articleId = extractArticleId(href); + if (articleId) { + anchor.setAttribute("data-article-id", articleId); + anchor.setAttribute("class", "opencom-article-link"); + anchor.setAttribute("href", `article:${articleId}`); + anchor.removeAttribute("target"); + anchor.removeAttribute("rel"); + } else { + anchor.removeAttribute("href"); + anchor.removeAttribute("target"); + anchor.removeAttribute("rel"); + } + return; + } + + if (hasDisallowedAbsoluteProtocol(href)) { anchor.removeAttribute("href"); anchor.removeAttribute("target"); anchor.removeAttribute("rel"); @@ -149,16 +178,15 @@ export function toPlainTextExcerpt(markdownInput: string, maxLength = 100): stri return `${normalizedText.slice(0, safeMaxLength).trimEnd()}...`; } -export function parseMarkdown( - markdownInput: string, - options?: ParseMarkdownOptions -): string { +export function parseMarkdown(markdownInput: string, options?: ParseMarkdownOptions): string { const contentWithoutFrontmatter = stripMarkdownFrontmatter(markdownInput); const rendered = markdown.render(contentWithoutFrontmatter); const sanitized = DOMPurify.sanitize(rendered, { ALLOWED_TAGS, ALLOWED_ATTR, FORBID_ATTR: ["style"], + ALLOWED_URI_REGEXP: + /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|article):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i, }); return enforceSafeLinksAndMedia(sanitized, resolveParseMarkdownOptions(options)); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 66f2c32..e0c8952 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,15 +6,24 @@ settings: overrides: '@isaacs/brace-expansion': 5.0.1 + '@xmldom/xmldom@0.7.13': 0.8.12 + '@xmldom/xmldom@0.8.11': 0.8.12 esbuild: ^0.27.0 + fast-xml-parser: 4.5.5 + flatted: 3.4.2 minimatch: 10.2.3 + node-forge: 1.4.0 '@expo/cli>minimatch': 10.2.3 '@expo/fingerprint>minimatch': 10.2.3 '@expo/metro-config>minimatch': 10.2.3 glob@13.0.0>minimatch: 10.2.3 glob@10.5.0>minimatch: 10.2.3 + picomatch@2.3.1: 2.3.2 + picomatch@3.0.1: 3.0.2 + picomatch@4.0.3: 4.0.4 rollup: 4.59.0 tar: 7.5.11 + vite@7.3.1: 7.3.2 '@react-native-community/cli': 17.0.1 '@react-native-community/cli-server-api': 17.0.1 '@react-native-community/cli-tools': 17.0.1 @@ -49,8 +58,8 @@ importers: specifier: ^4.0.17 version: 4.0.17(vitest@4.0.17) convex: - specifier: ^1.32.0 - version: 1.32.0(react@19.2.3) + specifier: 1.35.1 + version: 1.35.1(react@19.2.3) eslint: specifier: ^8.55.0 version: 8.57.1 @@ -95,13 +104,13 @@ importers: version: 12.34.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3) geist: specifier: ^1.7.0 - version: 1.7.0(next@15.5.12(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)) + version: 1.7.0(next@15.5.15(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)) lucide-react: specifier: ^0.469.0 version: 0.469.0(react@19.2.3) next: - specifier: ^15.5.10 - version: 15.5.12(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: ^15.5.15 + version: 15.5.15(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react: specifier: ^19.2.3 version: 19.2.3 @@ -138,7 +147,7 @@ importers: dependencies: '@convex-dev/auth': specifier: ^0.0.90 - version: 0.0.90(@auth/core@0.41.1)(convex@1.32.0(react@19.1.0))(react@19.1.0) + version: 0.0.90(@auth/core@0.41.1)(convex@1.35.1(react@19.1.0))(react@19.1.0) '@opencom/convex': specifier: workspace:* version: link:../../packages/convex @@ -149,8 +158,8 @@ importers: specifier: ^2.1.2 version: 2.2.0(react-native@0.81.5(@babel/core@7.28.6)(@react-native-community/cli@17.0.1(typescript@5.9.3))(@react-native/metro-config@0.83.1(@babel/core@7.28.6))(@types/react@19.1.17)(react@19.1.0)) convex: - specifier: ^1.32.0 - version: 1.32.0(react@19.1.0) + specifier: 1.35.1 + version: 1.35.1(react@19.1.0) expo: specifier: ~54.0.33 version: 54.0.33(@babel/core@7.28.6)(@expo/metro-runtime@5.0.4(react-native@0.81.5(@babel/core@7.28.6)(@react-native-community/cli@17.0.1(typescript@5.9.3))(@react-native/metro-config@0.83.1(@babel/core@7.28.6))(@types/react@19.1.17)(react@19.1.0)))(expo-router@6.0.23)(graphql@15.8.0)(react-native@0.81.5(@babel/core@7.28.6)(@react-native-community/cli@17.0.1(typescript@5.9.3))(@react-native/metro-config@0.83.1(@babel/core@7.28.6))(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) @@ -211,7 +220,7 @@ importers: dependencies: '@convex-dev/auth': specifier: ^0.0.90 - version: 0.0.90(@auth/core@0.41.1)(convex@1.32.0(react@19.2.3))(react@19.2.3) + version: 0.0.90(@auth/core@0.41.1)(convex@1.35.1(react@19.2.3))(react@19.2.3) '@opencom/convex': specifier: workspace:* version: link:../../packages/convex @@ -225,8 +234,8 @@ importers: specifier: workspace:* version: link:../../packages/web-shared convex: - specifier: ^1.32.0 - version: 1.32.0(react@19.2.3) + specifier: 1.35.1 + version: 1.35.1(react@19.2.3) dompurify: specifier: ^3.3.1 version: 3.3.1 @@ -240,8 +249,8 @@ importers: specifier: ^14.1.1 version: 14.1.1 next: - specifier: ^15.5.10 - version: 15.5.12(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + specifier: ^15.5.15 + version: 15.5.15(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react: specifier: ^19.2.3 version: 19.2.3 @@ -269,7 +278,7 @@ importers: version: 19.2.3(@types/react@19.2.9) '@vitejs/plugin-react': specifier: ^4.3.0 - version: 4.7.0(vite@7.3.1(@types/node@20.19.30)(jiti@1.21.7)(lightningcss@1.31.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + version: 4.7.0(vite@7.3.2(@types/node@20.19.30)(jiti@1.21.7)(lightningcss@1.31.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) autoprefixer: specifier: ^10.4.16 version: 10.4.23(postcss@8.5.6) @@ -304,8 +313,8 @@ importers: specifier: workspace:* version: link:../../packages/web-shared convex: - specifier: ^1.32.0 - version: 1.32.0(react@19.2.3) + specifier: 1.35.1 + version: 1.35.1(react@19.2.3) dompurify: specifier: ^3.3.1 version: 3.3.1 @@ -366,7 +375,7 @@ importers: version: 0.41.1 '@convex-dev/auth': specifier: ^0.0.90 - version: 0.0.90(@auth/core@0.41.1)(convex@1.32.0(react@19.2.3))(react@19.2.3) + version: 0.0.90(@auth/core@0.41.1)(convex@1.35.1(react@19.2.3))(react@19.2.3) '@opencom/types': specifier: workspace:* version: link:../types @@ -374,11 +383,11 @@ importers: specifier: ^6.0.48 version: 6.0.48(zod@4.3.5) convex: - specifier: ^1.32.0 - version: 1.32.0(react@19.2.3) + specifier: 1.35.1 + version: 1.35.1(react@19.2.3) convex-test: specifier: ^0.0.41 - version: 0.0.41(convex@1.32.0(react@19.2.3)) + version: 0.0.41(convex@1.35.1(react@19.2.3)) jose: specifier: ^6.1.3 version: 6.1.3 @@ -417,8 +426,8 @@ importers: specifier: ^1.23.0 version: 1.24.0(react-native@0.81.5(@babel/core@7.28.6)(@react-native-community/cli@17.0.1(typescript@5.0.4))(@react-native/metro-config@0.83.1(@babel/core@7.28.6))(@types/react@19.1.17)(react@19.2.3)) convex: - specifier: ^1.32.0 - version: 1.32.0(react@19.2.3) + specifier: 1.35.1 + version: 1.35.1(react@19.2.3) expo: specifier: '*' version: 54.0.31(@babel/core@7.28.6)(@expo/metro-runtime@5.0.4(react-native@0.81.5(@babel/core@7.28.6)(@react-native-community/cli@17.0.1(typescript@5.0.4))(@react-native/metro-config@0.83.1(@babel/core@7.28.6))(@types/react@19.1.17)(react@19.2.3)))(expo-router@6.0.23)(graphql@15.8.0)(react-native@0.81.5(@babel/core@7.28.6)(@react-native-community/cli@17.0.1(typescript@5.0.4))(@react-native/metro-config@0.83.1(@babel/core@7.28.6))(@types/react@19.1.17)(react@19.2.3))(react@19.2.3) @@ -521,8 +530,8 @@ importers: specifier: workspace:* version: link:../types convex: - specifier: ^1.32.0 - version: 1.32.0(react@19.2.3) + specifier: 1.35.1 + version: 1.35.1(react@19.2.3) devDependencies: tsup: specifier: ^8.0.0 @@ -2014,53 +2023,53 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} - '@next/env@15.5.12': - resolution: {integrity: sha512-pUvdJN1on574wQHjaBfNGDt9Mz5utDSZFsIIQkMzPgNS8ZvT4H2mwOrOIClwsQOb6EGx5M76/CZr6G8i6pSpLg==} + '@next/env@15.5.15': + resolution: {integrity: sha512-vcmyu5/MyFzN7CdqRHO3uHO44p/QPCZkuTUXroeUmhNP8bL5PHFEhik22JUazt+CDDoD6EpBYRCaS2pISL+/hg==} - '@next/swc-darwin-arm64@15.5.12': - resolution: {integrity: sha512-RnRjBtH8S8eXCpUNkQ+543DUc7ys8y15VxmFU9HRqlo9BG3CcBUiwNtF8SNoi2xvGCVJq1vl2yYq+3oISBS0Zg==} + '@next/swc-darwin-arm64@15.5.15': + resolution: {integrity: sha512-6PvFO2Tzt10GFK2Ro9tAVEtacMqRmTarYMFKAnV2vYMdwWc73xzmDQyAV7SwEdMhzmiRoo7+m88DuiXlJlGeaw==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@15.5.12': - resolution: {integrity: sha512-nqa9/7iQlboF1EFtNhWxQA0rQstmYRSBGxSM6g3GxvxHxcoeqVXfGNr9stJOme674m2V7r4E3+jEhhGvSQhJRA==} + '@next/swc-darwin-x64@15.5.15': + resolution: {integrity: sha512-G+YNV+z6FDZTp/+IdGyIMFqalBTaQSnvAA+X/hrt+eaTRFSznRMz9K7rTmzvM6tDmKegNtyzgufZW0HwVzEqaQ==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@15.5.12': - resolution: {integrity: sha512-dCzAjqhDHwmoB2M4eYfVKqXs99QdQxNQVpftvP1eGVppamXh/OkDAwV737Zr0KPXEqRUMN4uCjh6mjO+XtF3Mw==} + '@next/swc-linux-arm64-gnu@15.5.15': + resolution: {integrity: sha512-eVkrMcVIBqGfXB+QUC7jjZ94Z6uX/dNStbQFabewAnk13Uy18Igd1YZ/GtPRzdhtm7QwC0e6o7zOQecul4iC1w==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-musl@15.5.12': - resolution: {integrity: sha512-+fpGWvQiITgf7PUtbWY1H7qUSnBZsPPLyyq03QuAKpVoTy/QUx1JptEDTQMVvQhvizCEuNLEeghrQUyXQOekuw==} + '@next/swc-linux-arm64-musl@15.5.15': + resolution: {integrity: sha512-RwSHKMQ7InLy5GfkY2/n5PcFycKA08qI1VST78n09nN36nUPqCvGSMiLXlfUmzmpQpF6XeBYP2KRWHi0UW3uNg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-x64-gnu@15.5.12': - resolution: {integrity: sha512-jSLvgdRRL/hrFAPqEjJf1fFguC719kmcptjNVDJl26BnJIpjL3KH5h6mzR4mAweociLQaqvt4UyzfbFjgAdDcw==} + '@next/swc-linux-x64-gnu@15.5.15': + resolution: {integrity: sha512-nplqvY86LakS+eeiuWsNWvfmK8pFcOEW7ZtVRt4QH70lL+0x6LG/m1OpJ/tvrbwjmR8HH9/fH2jzW1GlL03TIg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-linux-x64-musl@15.5.12': - resolution: {integrity: sha512-/uaF0WfmYqQgLfPmN6BvULwxY0dufI2mlN2JbOKqqceZh1G4hjREyi7pg03zjfyS6eqNemHAZPSoP84x17vo6w==} + '@next/swc-linux-x64-musl@15.5.15': + resolution: {integrity: sha512-eAgl9NKQ84/sww0v81DQINl/vL2IBxD7sMybd0cWRw6wqgouVI53brVRBrggqBRP/NWeIAE1dm5cbKYoiMlqDQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-win32-arm64-msvc@15.5.12': - resolution: {integrity: sha512-xhsL1OvQSfGmlL5RbOmU+FV120urrgFpYLq+6U8C6KIym32gZT6XF/SDE92jKzzlPWskkbjOKCpqk5m4i8PEfg==} + '@next/swc-win32-arm64-msvc@15.5.15': + resolution: {integrity: sha512-GJVZC86lzSquh0MtvZT+L7G8+jMnJcldloOjA8Kf3wXvBrvb6OGe2MzPuALxFshSm/IpwUtD2mIoof39ymf52A==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@15.5.12': - resolution: {integrity: sha512-Z1Dh6lhFkxvBDH1FoW6OU/L6prYwPSlwjLiZkExIAh8fbP6iI/M7iGTQAJPYJ9YFlWobCZ1PHbchFhFYb2ADkw==} + '@next/swc-win32-x64-msvc@15.5.15': + resolution: {integrity: sha512-nFucjVdwlFqxh/JG3hWSJ4p8+YJV7Ii8aPDuBQULB6DzUF4UNZETXLfEUk+oI2zEznWWULPt7MeuTE6xtK1HSA==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -3077,7 +3086,7 @@ packages: resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} engines: {node: ^14.18.0 || >=16.0.0} peerDependencies: - vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + vite: 7.3.2 '@vitest/coverage-v8@4.0.17': resolution: {integrity: sha512-/6zU2FLGg0jsd+ePZcwHRy3+WpNTBBhDY56P4JTRqUN/Dp6CvOEa9HrikcQ4KfV2b2kAHUFB4dl1SuocWXSFEw==} @@ -3095,7 +3104,7 @@ packages: resolution: {integrity: sha512-+ZtQhLA3lDh1tI2wxe3yMsGzbp7uuJSWBM1iTIKCbppWTSBN09PUC+L+fyNlQApQoR+Ps8twt2pbSSXg2fQVEQ==} peerDependencies: msw: ^2.4.9 - vite: ^6.0.0 || ^7.0.0-0 + vite: 7.3.2 peerDependenciesMeta: msw: optional: true @@ -3136,13 +3145,8 @@ packages: '@vscode/sudo-prompt@9.3.2': resolution: {integrity: sha512-gcXoCN00METUNFeQOFJ+C9xUI0DKB+0EGMVg7wbVYRHBw2Eq3fKisDZOkRdOz3kqXRKOENMfShPOmypw1/8nOw==} - '@xmldom/xmldom@0.7.13': - resolution: {integrity: sha512-lm2GW5PkosIzccsaZIz7tp8cPADSIlIHWDFTR1N0SzfinhhYgeIQjFMz4rYzanCScr3DqQLeomUDArp6MWKm+g==} - engines: {node: '>=10.0.0'} - deprecated: this version is no longer supported, please update to at least 0.8.* - - '@xmldom/xmldom@0.8.11': - resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} + '@xmldom/xmldom@0.8.12': + resolution: {integrity: sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==} engines: {node: '>=10.0.0'} abort-controller@3.0.0: @@ -3663,19 +3667,22 @@ packages: peerDependencies: convex: ^1.16.4 - convex@1.32.0: - resolution: {integrity: sha512-5FlajdLpW75pdLS+/CgGH5H6yeRuA+ru50AKJEYbJpmyILUS+7fdTvsdTaQ7ZFXMv0gE8mX4S+S3AtJ94k0mfw==} + convex@1.35.1: + resolution: {integrity: sha512-g23KrTjBiXqRHzWIN0PVFagKjrmFxWUaOSiBsAWPTpXX2rXl0L1F4PR0YpAcMJEzMgfZR9AGymJvLTM+KA6lsQ==} engines: {node: '>=18.0.0', npm: '>=7.0.0'} hasBin: true peerDependencies: '@auth0/auth0-react': ^2.0.1 '@clerk/clerk-react': ^4.12.8 || ^5.0.0 + '@clerk/react': ^6.0.0 react: ^18.0.0 || ^19.0.0-0 || ^19.0.0 peerDependenciesMeta: '@auth0/auth0-react': optional: true '@clerk/clerk-react': optional: true + '@clerk/react': + optional: true react: optional: true @@ -4329,8 +4336,8 @@ packages: fast-sha256@1.3.0: resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} - fast-xml-parser@4.5.3: - resolution: {integrity: sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==} + fast-xml-parser@4.5.5: + resolution: {integrity: sha512-cK9c5I/DwIOI7/Q7AlGN3DuTdwN61gwSfL8rvuVPK+0mcCNHHGxRrpiFtaZZRfRMJL3Gl8B2AFlBG6qXf03w9A==} hasBin: true fastq@1.20.1: @@ -4343,7 +4350,7 @@ packages: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} peerDependencies: - picomatch: ^3 || ^4 + picomatch: 3.0.2 peerDependenciesMeta: picomatch: optional: true @@ -4382,8 +4389,8 @@ packages: resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} engines: {node: ^10.12.0 || >=12.0.0} - flatted@3.3.3: - resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} flow-enums-runtime@0.0.6: resolution: {integrity: sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==} @@ -5437,8 +5444,8 @@ packages: nested-error-stacks@2.0.1: resolution: {integrity: sha512-SrQrok4CATudVzBS7coSz26QRSmlK9TzzoFbeKfcPBUFPjcQM9Rqvr/DlJkOrwI/0KcgvMub1n1g5Jt9EgRn4A==} - next@15.5.12: - resolution: {integrity: sha512-Fi/wQ4Etlrn60rz78bebG1i1SR20QxvV8tVp6iJspjLUSHcZoeUXCt+vmWoEcza85ElZzExK/jJ/F6SvtGktjA==} + next@15.5.15: + resolution: {integrity: sha512-VSqCrJwtLVGwAVE0Sb/yikrQfkwkZW9p+lL/J4+xe+G3ZA+QnWPqgcfH1tDUEuk9y+pthzzVFp4L/U8JerMfMQ==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} hasBin: true peerDependencies: @@ -5462,8 +5469,8 @@ packages: resolution: {integrity: sha512-WDD0bdg9mbq6F4mRxEYcPWwfA1vxd0mrvKOyxI7Xj/atfRHVeutzuWByG//jfm4uPzp0y4Kj051EORCBSQMycw==} engines: {node: '>=12.0.0'} - node-forge@1.3.3: - resolution: {integrity: sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==} + node-forge@1.4.0: + resolution: {integrity: sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==} engines: {node: '>= 6.13.0'} node-int64@0.4.0: @@ -5686,16 +5693,16 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} engines: {node: '>=8.6'} - picomatch@3.0.1: - resolution: {integrity: sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag==} + picomatch@3.0.2: + resolution: {integrity: sha512-cfDHL6LStTEKlNilboNtobT/kEa30PtAf2Q1OgszfrG/rpVl1xaFWT9ktfkS306GmHgmnad1Sw4wabhlvFtsTw==} engines: {node: '>=10'} - picomatch@4.0.3: - resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} pify@2.3.0: @@ -6811,7 +6818,7 @@ packages: vite-plugin-css-injected-by-js@3.5.2: resolution: {integrity: sha512-2MpU/Y+SCZyWUB6ua3HbJCrgnF0KACAsmzOQt1UvRVJCGF6S8xdA3ZUhWcWdM9ivG4I5az8PnQmwwrkC2CAQrQ==} peerDependencies: - vite: '>2.0.0-0' + vite: 7.3.2 vite@5.4.21: resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} @@ -6844,8 +6851,8 @@ packages: terser: optional: true - vite@7.3.1: - resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} + vite@7.3.2: + resolution: {integrity: sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -8113,12 +8120,12 @@ snapshots: '@cloudflare/workerd-windows-64@1.20260217.0': optional: true - '@convex-dev/auth@0.0.90(@auth/core@0.41.1)(convex@1.32.0(react@19.1.0))(react@19.1.0)': + '@convex-dev/auth@0.0.90(@auth/core@0.41.1)(convex@1.35.1(react@19.1.0))(react@19.1.0)': dependencies: '@auth/core': 0.41.1 '@oslojs/crypto': 1.0.1 '@oslojs/encoding': 1.1.0 - convex: 1.32.0(react@19.1.0) + convex: 1.35.1(react@19.1.0) cookie: 1.1.1 is-network-error: 1.3.0 jose: 5.10.0 @@ -8130,12 +8137,12 @@ snapshots: optionalDependencies: react: 19.1.0 - '@convex-dev/auth@0.0.90(@auth/core@0.41.1)(convex@1.32.0(react@19.2.3))(react@19.2.3)': + '@convex-dev/auth@0.0.90(@auth/core@0.41.1)(convex@1.35.1(react@19.2.3))(react@19.2.3)': dependencies: '@auth/core': 0.41.1 '@oslojs/crypto': 1.0.1 '@oslojs/encoding': 1.1.0 - convex: 1.32.0(react@19.2.3) + convex: 1.35.1(react@19.2.3) cookie: 1.1.1 is-network-error: 1.3.0 jose: 5.10.0 @@ -8328,10 +8335,10 @@ snapshots: glob: 13.0.0 lan-network: 0.1.7 minimatch: 10.2.3 - node-forge: 1.3.3 + node-forge: 1.4.0 npm-package-arg: 11.0.3 ora: 3.4.0 - picomatch: 3.0.1 + picomatch: 3.0.2 pretty-bytes: 5.6.0 pretty-format: 29.7.0 progress: 2.0.3 @@ -8403,10 +8410,10 @@ snapshots: glob: 13.0.0 lan-network: 0.1.7 minimatch: 10.2.3 - node-forge: 1.3.3 + node-forge: 1.4.0 npm-package-arg: 11.0.3 ora: 3.4.0 - picomatch: 3.0.1 + picomatch: 3.0.2 pretty-bytes: 5.6.0 pretty-format: 29.7.0 progress: 2.0.3 @@ -8478,10 +8485,10 @@ snapshots: glob: 13.0.0 lan-network: 0.1.7 minimatch: 10.2.3 - node-forge: 1.3.3 + node-forge: 1.4.0 npm-package-arg: 11.0.3 ora: 3.4.0 - picomatch: 3.0.1 + picomatch: 3.0.2 pretty-bytes: 5.6.0 pretty-format: 29.7.0 progress: 2.0.3 @@ -8514,7 +8521,7 @@ snapshots: '@expo/code-signing-certificates@0.0.6': dependencies: - node-forge: 1.3.3 + node-forge: 1.4.0 '@expo/config-plugins@54.0.4': dependencies: @@ -8810,13 +8817,13 @@ snapshots: '@expo/plist@0.1.3': dependencies: - '@xmldom/xmldom': 0.7.13 + '@xmldom/xmldom': 0.8.12 base64-js: 1.5.1 xmlbuilder: 14.0.0 '@expo/plist@0.4.8': dependencies: - '@xmldom/xmldom': 0.8.11 + '@xmldom/xmldom': 0.8.12 base64-js: 1.5.1 xmlbuilder: 15.1.1 @@ -9136,30 +9143,30 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@next/env@15.5.12': {} + '@next/env@15.5.15': {} - '@next/swc-darwin-arm64@15.5.12': + '@next/swc-darwin-arm64@15.5.15': optional: true - '@next/swc-darwin-x64@15.5.12': + '@next/swc-darwin-x64@15.5.15': optional: true - '@next/swc-linux-arm64-gnu@15.5.12': + '@next/swc-linux-arm64-gnu@15.5.15': optional: true - '@next/swc-linux-arm64-musl@15.5.12': + '@next/swc-linux-arm64-musl@15.5.15': optional: true - '@next/swc-linux-x64-gnu@15.5.12': + '@next/swc-linux-x64-gnu@15.5.15': optional: true - '@next/swc-linux-x64-musl@15.5.12': + '@next/swc-linux-x64-musl@15.5.15': optional: true - '@next/swc-win32-arm64-msvc@15.5.12': + '@next/swc-win32-arm64-msvc@15.5.15': optional: true - '@next/swc-win32-x64-msvc@15.5.12': + '@next/swc-win32-x64-msvc@15.5.15': optional: true '@node-rs/argon2-android-arm-eabi@1.7.0': @@ -10021,7 +10028,7 @@ snapshots: '@react-native-community/cli-tools': 17.0.1 chalk: 4.1.2 fast-glob: 3.3.3 - fast-xml-parser: 4.5.3 + fast-xml-parser: 4.5.5 '@react-native-community/cli-config-apple@17.0.1': dependencies: @@ -10106,7 +10113,7 @@ snapshots: '@react-native-community/cli-tools': 17.0.1 chalk: 4.1.2 execa: 5.1.1 - fast-xml-parser: 4.5.3 + fast-xml-parser: 4.5.5 '@react-native-community/cli-platform-ios@17.0.1': dependencies: @@ -10987,7 +10994,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitejs/plugin-react@4.7.0(vite@7.3.1(@types/node@20.19.30)(jiti@1.21.7)(lightningcss@1.31.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + '@vitejs/plugin-react@4.7.0(vite@7.3.2(@types/node@20.19.30)(jiti@1.21.7)(lightningcss@1.31.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@babel/core': 7.28.6 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.6) @@ -10995,7 +11002,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 7.3.1(@types/node@20.19.30)(jiti@1.21.7)(lightningcss@1.31.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.2(@types/node@20.19.30)(jiti@1.21.7)(lightningcss@1.31.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color @@ -11022,13 +11029,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.0.3 - '@vitest/mocker@4.0.17(vite@7.3.1(@types/node@20.19.30)(jiti@1.21.7)(lightningcss@1.31.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/mocker@4.0.17(vite@7.3.2(@types/node@20.19.30)(jiti@1.21.7)(lightningcss@1.31.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 4.0.17 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@20.19.30)(jiti@1.21.7)(lightningcss@1.31.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.2(@types/node@20.19.30)(jiti@1.21.7)(lightningcss@1.31.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) '@vitest/pretty-format@4.0.17': dependencies: @@ -11056,7 +11063,7 @@ snapshots: dependencies: '@vitest/utils': 4.0.17 fflate: 0.8.2 - flatted: 3.3.3 + flatted: 3.4.2 pathe: 2.0.3 sirv: 3.0.2 tinyglobby: 0.2.15 @@ -11067,7 +11074,7 @@ snapshots: dependencies: '@vitest/utils': 4.0.18 fflate: 0.8.2 - flatted: 3.3.3 + flatted: 3.4.2 pathe: 2.0.3 sirv: 3.0.2 tinyglobby: 0.2.15 @@ -11088,9 +11095,7 @@ snapshots: '@vscode/sudo-prompt@9.3.2': {} - '@xmldom/xmldom@0.7.13': {} - - '@xmldom/xmldom@0.8.11': {} + '@xmldom/xmldom@0.8.12': {} abort-controller@3.0.0: dependencies: @@ -11164,7 +11169,7 @@ snapshots: anymatch@3.1.3: dependencies: normalize-path: 3.0.0 - picomatch: 2.3.1 + picomatch: 2.3.2 appdirsjs@1.2.7: {} @@ -11770,11 +11775,11 @@ snapshots: convert-source-map@2.0.0: {} - convex-test@0.0.41(convex@1.32.0(react@19.2.3)): + convex-test@0.0.41(convex@1.35.1(react@19.2.3)): dependencies: - convex: 1.32.0(react@19.2.3) + convex: 1.35.1(react@19.2.3) - convex@1.32.0(react@19.1.0): + convex@1.35.1(react@19.1.0): dependencies: esbuild: 0.27.0 prettier: 3.8.0 @@ -11785,7 +11790,7 @@ snapshots: - bufferutil - utf-8-validate - convex@1.32.0(react@19.2.3): + convex@1.35.1(react@19.2.3): dependencies: esbuild: 0.27.0 prettier: 3.8.0 @@ -12854,7 +12859,7 @@ snapshots: fast-sha256@1.3.0: {} - fast-xml-parser@4.5.3: + fast-xml-parser@4.5.5: dependencies: strnum: 1.1.2 @@ -12866,9 +12871,9 @@ snapshots: dependencies: bser: 2.1.1 - fdir@6.5.0(picomatch@4.0.3): + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: - picomatch: 4.0.3 + picomatch: 4.0.4 fflate@0.8.2: {} @@ -12912,11 +12917,11 @@ snapshots: flat-cache@3.2.0: dependencies: - flatted: 3.3.3 + flatted: 3.4.2 keyv: 4.5.4 rimraf: 3.0.2 - flatted@3.3.3: {} + flatted@3.4.2: {} flow-enums-runtime@0.0.6: {} @@ -12982,9 +12987,9 @@ snapshots: functions-have-names@1.2.3: {} - geist@1.7.0(next@15.5.12(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)): + geist@1.7.0(next@15.5.15(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)): dependencies: - next: 15.5.12(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + next: 15.5.15(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) generator-function@2.0.1: {} @@ -13520,7 +13525,7 @@ snapshots: chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11 - picomatch: 2.3.1 + picomatch: 2.3.2 jest-validate@29.7.0: dependencies: @@ -14009,7 +14014,7 @@ snapshots: micromatch@4.0.8: dependencies: braces: 3.0.3 - picomatch: 2.3.1 + picomatch: 2.3.2 mime-db@1.52.0: {} @@ -14090,9 +14095,9 @@ snapshots: nested-error-stacks@2.0.1: {} - next@15.5.12(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + next@15.5.15(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: - '@next/env': 15.5.12 + '@next/env': 15.5.15 '@swc/helpers': 0.5.15 caniuse-lite: 1.0.30001765 postcss: 8.4.31 @@ -14100,14 +14105,14 @@ snapshots: react-dom: 19.2.3(react@19.2.3) styled-jsx: 5.1.6(@babel/core@7.28.6)(react@19.2.3) optionalDependencies: - '@next/swc-darwin-arm64': 15.5.12 - '@next/swc-darwin-x64': 15.5.12 - '@next/swc-linux-arm64-gnu': 15.5.12 - '@next/swc-linux-arm64-musl': 15.5.12 - '@next/swc-linux-x64-gnu': 15.5.12 - '@next/swc-linux-x64-musl': 15.5.12 - '@next/swc-win32-arm64-msvc': 15.5.12 - '@next/swc-win32-x64-msvc': 15.5.12 + '@next/swc-darwin-arm64': 15.5.15 + '@next/swc-darwin-x64': 15.5.15 + '@next/swc-linux-arm64-gnu': 15.5.15 + '@next/swc-linux-arm64-musl': 15.5.15 + '@next/swc-linux-x64-gnu': 15.5.15 + '@next/swc-linux-x64-musl': 15.5.15 + '@next/swc-win32-arm64-msvc': 15.5.15 + '@next/swc-win32-x64-msvc': 15.5.15 '@opentelemetry/api': 1.9.0 '@playwright/test': 1.57.0 babel-plugin-react-compiler: 1.0.0 @@ -14118,7 +14123,7 @@ snapshots: nocache@3.0.4: {} - node-forge@1.3.3: {} + node-forge@1.4.0: {} node-int64@0.4.0: {} @@ -14356,11 +14361,11 @@ snapshots: picocolors@1.1.1: {} - picomatch@2.3.1: {} + picomatch@2.3.2: {} - picomatch@3.0.1: {} + picomatch@3.0.2: {} - picomatch@4.0.3: {} + picomatch@4.0.4: {} pify@2.3.0: {} @@ -14382,7 +14387,7 @@ snapshots: plist@3.1.0: dependencies: - '@xmldom/xmldom': 0.8.11 + '@xmldom/xmldom': 0.8.12 base64-js: 1.5.1 xmlbuilder: 15.1.1 @@ -15020,7 +15025,7 @@ snapshots: readdirp@3.6.0: dependencies: - picomatch: 2.3.1 + picomatch: 2.3.2 readdirp@4.1.2: {} @@ -15640,8 +15645,8 @@ snapshots: tinyglobby@0.2.15: dependencies: - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 tinyrainbow@3.0.3: {} @@ -15955,11 +15960,11 @@ snapshots: lightningcss: 1.31.0 terser: 5.46.0 - vite@7.3.1(@types/node@20.19.30)(jiti@1.21.7)(lightningcss@1.31.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): + vite@7.3.2(@types/node@20.19.30)(jiti@1.21.7)(lightningcss@1.31.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.27.0 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 postcss: 8.5.6 rollup: 4.59.0 tinyglobby: 0.2.15 @@ -15975,7 +15980,7 @@ snapshots: vitest@4.0.17(@opentelemetry/api@1.9.0)(@types/node@20.19.30)(@vitest/ui@4.0.17)(jiti@1.21.7)(jsdom@26.1.0)(lightningcss@1.31.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@vitest/expect': 4.0.17 - '@vitest/mocker': 4.0.17(vite@7.3.1(@types/node@20.19.30)(jiti@1.21.7)(lightningcss@1.31.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/mocker': 4.0.17(vite@7.3.2(@types/node@20.19.30)(jiti@1.21.7)(lightningcss@1.31.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/pretty-format': 4.0.17 '@vitest/runner': 4.0.17 '@vitest/snapshot': 4.0.17 @@ -15986,13 +15991,13 @@ snapshots: magic-string: 0.30.21 obug: 2.1.1 pathe: 2.0.3 - picomatch: 4.0.3 + picomatch: 4.0.4 std-env: 3.10.0 tinybench: 2.9.0 tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 7.3.1(@types/node@20.19.30)(jiti@1.21.7)(lightningcss@1.31.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.2(@types/node@20.19.30)(jiti@1.21.7)(lightningcss@1.31.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.0 @@ -16015,7 +16020,7 @@ snapshots: vitest@4.0.17(@opentelemetry/api@1.9.0)(@types/node@20.19.30)(@vitest/ui@4.0.18)(jiti@1.21.7)(jsdom@26.1.0)(lightningcss@1.31.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@vitest/expect': 4.0.17 - '@vitest/mocker': 4.0.17(vite@7.3.1(@types/node@20.19.30)(jiti@1.21.7)(lightningcss@1.31.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/mocker': 4.0.17(vite@7.3.2(@types/node@20.19.30)(jiti@1.21.7)(lightningcss@1.31.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/pretty-format': 4.0.17 '@vitest/runner': 4.0.17 '@vitest/snapshot': 4.0.17 @@ -16026,13 +16031,13 @@ snapshots: magic-string: 0.30.21 obug: 2.1.1 pathe: 2.0.3 - picomatch: 4.0.3 + picomatch: 4.0.4 std-env: 3.10.0 tinybench: 2.9.0 tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 7.3.1(@types/node@20.19.30)(jiti@1.21.7)(lightningcss@1.31.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.2(@types/node@20.19.30)(jiti@1.21.7)(lightningcss@1.31.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.0 diff --git a/scripts/screenshots/seed.ts b/scripts/screenshots/seed.ts index 35af639..879da01 100644 --- a/scripts/screenshots/seed.ts +++ b/scripts/screenshots/seed.ts @@ -40,7 +40,7 @@ export async function callMutation( }, body: JSON.stringify({ path: "testAdmin:runTestMutation", - args: { secret, name: path, mutationArgs: args }, + args: { secret, name: path, mutationArgsJson: JSON.stringify(args) }, format: "json", }), }); diff --git a/security/convex-v-any-arg-exceptions.json b/security/convex-v-any-arg-exceptions.json index 111381a..e9d524d 100644 --- a/security/convex-v-any-arg-exceptions.json +++ b/security/convex-v-any-arg-exceptions.json @@ -1,17 +1,5 @@ { "version": 1, "description": "Temporary v.any() exceptions for exported handler args.", - "entries": [ - { - "file": "packages/convex/convex/testAdmin.ts", - "handler": "runTestMutation", - "kind": "action", - "riskTier": "low", - "owner": "backend-security", - "reason": "Admin test gateway intentionally forwards arbitrary mutation args to internal test-only functions.", - "originalExpiresOn": "2026-04-15", - "expiresOn": "2026-04-15", - "justificationUpdatedOn": "2026-02-14" - } - ] + "entries": [] } diff --git a/security/dependency-audit-allowlist.json b/security/dependency-audit-allowlist.json index ae32366..78b5966 100644 --- a/security/dependency-audit-allowlist.json +++ b/security/dependency-audit-allowlist.json @@ -48,6 +48,54 @@ "expiresOn": "2026-06-30", "reason": "Transitive dev dependency through eslint -> file-entry-cache -> flat-cache -> flatted. Only used in dev toolchain for ESLint cache, not in production code.", "cleanupCriteria": "Remove when ESLint or flat-cache upgrades to flatted >= 3.3.4 which includes the fix." + }, + { + "id": "1114591", + "module": "undici", + "owner": "platform-team", + "expiresOn": "2026-06-30", + "reason": "Transitive dev dependency through wrangler -> miniflare for Cloudflare Workers local development. Not used in production. WebSocket vulnerabilities only affect dev environment connecting to local servers.", + "cleanupCriteria": "Remove when miniflare or wrangler upgrades to undici with fixes." + }, + { + "id": "1114592", + "module": "undici", + "owner": "platform-team", + "expiresOn": "2026-06-30", + "reason": "Transitive dev dependency through wrangler -> miniflare for Cloudflare Workers local development. Not used in production. WebSocket vulnerabilities only affect dev environment connecting to local servers.", + "cleanupCriteria": "Remove when miniflare or wrangler upgrades to undici with fixes." + }, + { + "id": "1114637", + "module": "undici", + "owner": "platform-team", + "expiresOn": "2026-06-30", + "reason": "Transitive dev dependency through wrangler -> miniflare for Cloudflare Workers local development. Not used in production. WebSocket vulnerabilities only affect dev environment connecting to local servers.", + "cleanupCriteria": "Remove when miniflare or wrangler upgrades to undici with fixes." + }, + { + "id": "1114638", + "module": "undici", + "owner": "platform-team", + "expiresOn": "2026-06-30", + "reason": "Transitive dev dependency through wrangler -> miniflare for Cloudflare Workers local development. Not used in production. WebSocket vulnerabilities only affect dev environment connecting to local servers.", + "cleanupCriteria": "Remove when miniflare or wrangler upgrades to undici with fixes." + }, + { + "id": "1114639", + "module": "undici", + "owner": "platform-team", + "expiresOn": "2026-06-30", + "reason": "Transitive dev dependency through wrangler -> miniflare for Cloudflare Workers local development. Not used in production. WebSocket vulnerabilities only affect dev environment connecting to local servers.", + "cleanupCriteria": "Remove when miniflare or wrangler upgrades to undici with fixes." + }, + { + "id": "1114640", + "module": "undici", + "owner": "platform-team", + "expiresOn": "2026-06-30", + "reason": "Transitive dev dependency through wrangler -> miniflare for Cloudflare Workers local development. Not used in production. WebSocket vulnerabilities only affect dev environment connecting to local servers.", + "cleanupCriteria": "Remove when miniflare or wrangler upgrades to undici with fixes." } ] }