From 2c3d26b133bcd8d7ac19b6ee7464856acd5fefbb Mon Sep 17 00:00:00 2001 From: Jack D Date: Fri, 13 Mar 2026 21:21:38 +0000 Subject: [PATCH 1/5] insert links to knowledge base from admin chat, and use vector search --- apps/web/src/app/inbox/InboxThreadPane.tsx | 18 +- .../web/src/app/inbox/hooks/useInboxConvex.ts | 47 +++- apps/web/src/app/inbox/page.tsx | 25 +- .../conversationView/MessageList.tsx | 27 +- apps/widget/src/styles.css | 231 ++++++++++++++---- .../.openspec.yaml | 2 + .../design.md | 79 ++++++ .../proposal.md | 30 +++ .../ai-help-center-linked-sources/spec.md | 11 + .../specs/inbox-knowledge-insertion/spec.md | 24 ++ .../inbox-knowledge-vector-search/spec.md | 27 ++ .../spec.md | 44 ++++ .../tasks.md | 46 ++++ .../ai-help-center-linked-sources/spec.md | 11 + .../specs/inbox-knowledge-insertion/spec.md | 28 ++- .../inbox-knowledge-vector-search/spec.md | 33 +++ .../spec.md | 18 ++ packages/convex/convex/knowledge.ts | 143 ++++++++++- packages/web-shared/src/markdown.test.ts | 19 ++ packages/web-shared/src/markdown.ts | 50 +++- 20 files changed, 817 insertions(+), 96 deletions(-) create mode 100644 openspec/changes/archive/2026-03-13-article-link-widget-integration/.openspec.yaml create mode 100644 openspec/changes/archive/2026-03-13-article-link-widget-integration/design.md create mode 100644 openspec/changes/archive/2026-03-13-article-link-widget-integration/proposal.md create mode 100644 openspec/changes/archive/2026-03-13-article-link-widget-integration/specs/ai-help-center-linked-sources/spec.md create mode 100644 openspec/changes/archive/2026-03-13-article-link-widget-integration/specs/inbox-knowledge-insertion/spec.md create mode 100644 openspec/changes/archive/2026-03-13-article-link-widget-integration/specs/inbox-knowledge-vector-search/spec.md create mode 100644 openspec/changes/archive/2026-03-13-article-link-widget-integration/specs/shared-markdown-rendering-sanitization/spec.md create mode 100644 openspec/changes/archive/2026-03-13-article-link-widget-integration/tasks.md create mode 100644 openspec/specs/inbox-knowledge-vector-search/spec.md diff --git a/apps/web/src/app/inbox/InboxThreadPane.tsx b/apps/web/src/app/inbox/InboxThreadPane.tsx index cd7e3a2..d0a2977 100644 --- a/apps/web/src/app/inbox/InboxThreadPane.tsx +++ b/apps/web/src/app/inbox/InboxThreadPane.tsx @@ -207,17 +207,25 @@ export function InboxThreadPane({ Insert Link - + */} ); } return ( - + + // ); }; diff --git a/apps/web/src/app/inbox/hooks/useInboxConvex.ts b/apps/web/src/app/inbox/hooks/useInboxConvex.ts index 2e55fc2..789264a 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,37 @@ 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; + + searchKnowledge({ workspaceId, query: knowledgeSearch, limit: 20 }) + .then((results) => { + if (!cancelled) { + setKnowledgeResults(results); + } + }) + .catch((error) => { + console.error("Knowledge search failed:", error); + if (!cancelled) { + setKnowledgeResults(undefined); + } + }); + + return () => { + cancelled = true; + }; + }, [workspaceId, knowledgeSearch, searchKnowledge]); + return { aiResponses: useWebQuery( AI_CONVERSATION_RESPONSES_QUERY_REF, @@ -188,16 +220,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/page.tsx b/apps/web/src/app/inbox/page.tsx index b7ed144..3f31710 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, @@ -381,8 +384,8 @@ function InboxContent(): React.JSX.Element | null { if (item.type === "snippet") { 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})`); + } else if (action === "link" && (item.type === "article" || item.type === "internalArticle")) { + setInputValue((prev) => `${prev}${prev ? "\n\n" : ""}[${item.title}](article:${item.id})`); } else { setInputValue((prev) => `${prev}${prev ? "\n\n" : ""}${item.content}`); } diff --git a/apps/widget/src/components/conversationView/MessageList.tsx b/apps/widget/src/components/conversationView/MessageList.tsx index 7d39dd2..5e4763e 100644 --- a/apps/widget/src/components/conversationView/MessageList.tsx +++ b/apps/widget/src/components/conversationView/MessageList.tsx @@ -71,6 +71,19 @@ export function ConversationMessageList({ renderedMessages, messagesEndRef, }: ConversationMessageListProps) { + const handleMessageClick = (event: React.MouseEvent) => { + const target = event.target as HTMLElement; + const articleLink = target.closest("[data-article-id]"); + if (articleLink) { + event.preventDefault(); + event.stopPropagation(); + const articleId = articleLink.getAttribute("data-article-id"); + if (articleId) { + onSelectArticle(articleId as Id<"articles">); + } + } + }; + return (
{!messages || messages.length === 0 ? ( @@ -102,7 +115,9 @@ export function ConversationMessageList({ return (
- {showTimestamp &&
{formatTime(msg._creationTime)}
} + {showTimestamp && ( +
{formatTime(msg._creationTime)}
+ )}
)} {isHumanAgent && ( - + {humanAgentName} )} @@ -125,6 +143,7 @@ export function ConversationMessageList({ dangerouslySetInnerHTML={{ __html: renderedMessages.get(msg._id) ?? "", }} + onClick={handleMessageClick} /> )} {msg.attachments && msg.attachments.length > 0 && ( @@ -150,7 +169,9 @@ export function ConversationMessageList({ type="button" className="opencom-ai-source-link" data-testid={`widget-ai-source-link-${aiData._id}-${sourceIndex}`} - onClick={() => onSelectArticle(articleSourceId as Id<"articles">)} + onClick={() => + onSelectArticle(articleSourceId as Id<"articles">) + } > {source.title} 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/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/packages/convex/convex/knowledge.ts b/packages/convex/convex/knowledge.ts index 6ffeaaa..1ae43c0 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 { makeFunctionReference, type FunctionReference } from "convex/server"; +import { embed } from "ai"; +import { authAction, authMutation, authQuery } from "./lib/authWrappers"; import { Id } from "./_generated/dataModel"; +import { Doc } from "./_generated/dataModel"; import type { MutationCtx, QueryCtx } from "./_generated/server"; +import { createAIClient } from "./lib/aiGateway"; import { getArticleVisibility, getUnifiedArticleByIdOrLegacyInternalId, @@ -232,6 +236,132 @@ export const search = authQuery({ }, }); +type EmbeddingQueryRef, Return> = FunctionReference< + "query", + "public", + Args, + Return +>; + +const GET_EMBEDDING_BY_ID_REF: EmbeddingQueryRef< + { id: Id<"contentEmbeddings"> }, + Doc<"contentEmbeddings"> | null +> = makeFunctionReference< + "query", + { id: Id<"contentEmbeddings"> }, + Doc<"contentEmbeddings"> | null +>("suggestions:getEmbeddingById"); + +type ContentRecord = { + content: string; + title: string; + slug?: string; + tags?: string[]; +} | null; + +const GET_CONTENT_BY_ID_REF: EmbeddingQueryRef< + { contentType: KnowledgeContentType; contentId: string }, + ContentRecord +> = makeFunctionReference< + "query", + { contentType: KnowledgeContentType; contentId: string }, + ContentRecord +>("suggestions:getContentById"); + +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 results = 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 enrichedResults: (KnowledgeSearchResult | null)[] = await Promise.all( + results.map( + async (result: { + _id: Id<"contentEmbeddings">; + _score: number; + }): Promise => { + const doc = await runQuery(GET_EMBEDDING_BY_ID_REF, { + id: result._id, + }); + if (!doc) return null; + if (contentTypeSet && !contentTypeSet.has(doc.contentType)) return null; + + const contentRecord = await runQuery(GET_CONTENT_BY_ID_REF, { + contentType: doc.contentType, + contentId: doc.contentId, + }); + if (!contentRecord) return null; + + return { + id: doc.contentId, + type: doc.contentType, + title: doc.title, + content: contentRecord.content, + snippet: doc.snippet, + slug: contentRecord.slug, + tags: contentRecord.tags, + relevanceScore: result._score, + updatedAt: doc.updatedAt, + }; + } + ) + ); + + const filtered = enrichedResults.filter((r): r is KnowledgeSearchResult => r !== null); + const deduped: KnowledgeSearchResult[] = []; + const seen = new Set(); + for (const result of filtered) { + const key = `${result.type}:${result.id}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + deduped.push(result); + if (deduped.length >= limit) { + break; + } + } + + return deduped; + }, +}); + export const trackAccess = authMutation({ args: { userId: v.id("users"), @@ -316,7 +446,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 +557,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/web-shared/src/markdown.test.ts b/packages/web-shared/src/markdown.test.ts index 8c52c43..2272e71 100644 --- a/packages/web-shared/src/markdown.test.ts +++ b/packages/web-shared/src/markdown.test.ts @@ -33,6 +33,25 @@ 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).not.toContain("href="); + 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="); + }); }); describe("frontmatter and excerpt helpers", () => { diff --git a/packages/web-shared/src/markdown.ts b/packages/web-shared/src/markdown.ts index 9335951..f82ab52 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 = { @@ -77,19 +77,48 @@ function hasDisallowedAbsoluteProtocol(rawUrl: string): boolean { return false; } const protocol = match[1].toLowerCase(); - return protocol !== "http" && protocol !== "https"; + return protocol !== "http" && protocol !== "https" && protocol !== "article"; } -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.removeAttribute("href"); + 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)); From 15769ab1dcf7029e372c77421bfc8afb55bc939d Mon Sep 17 00:00:00 2001 From: Jack D Date: Fri, 13 Mar 2026 21:48:59 +0000 Subject: [PATCH 2/5] Encourage checks in AGENTS, update dependency allowlist --- AGENTS.md | 1 + .../tests/runtimeTypeHardeningGuard.test.ts | 13 +++++ security/dependency-audit-allowlist.json | 48 +++++++++++++++++++ 3 files changed, 62 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index d51897c..0fe0438 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 diff --git a/packages/convex/tests/runtimeTypeHardeningGuard.test.ts b/packages/convex/tests/runtimeTypeHardeningGuard.test.ts index 43a78a7..8407f77 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,18 @@ 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("GET_EMBEDDING_BY_ID_REF"); + expect(knowledgeSource).toContain("GET_CONTENT_BY_ID_REF"); + expect(knowledgeSource).toContain("getShallowRunQuery"); + }); + it("uses fixed typed refs for support attachment cleanup scheduling", () => { const supportAttachmentsSource = readFileSync( new URL("../convex/supportAttachments.ts", import.meta.url), 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." } ] } From bcefe7ec8598a89d4a5fbedec7e323add2b2b49c Mon Sep 17 00:00:00 2001 From: Jack D Date: Fri, 13 Mar 2026 22:44:07 +0000 Subject: [PATCH 3/5] Remove dead code --- apps/web/src/app/inbox/InboxThreadPane.tsx | 22 ++-- .../web/src/app/inbox/hooks/useInboxConvex.ts | 28 ++--- apps/web/src/app/inbox/page.tsx | 2 +- packages/convex/convex/knowledge.ts | 104 ++++++++++-------- packages/web-shared/src/markdown.test.ts | 8 +- packages/web-shared/src/markdown.ts | 4 +- 6 files changed, 92 insertions(+), 76 deletions(-) diff --git a/apps/web/src/app/inbox/InboxThreadPane.tsx b/apps/web/src/app/inbox/InboxThreadPane.tsx index d0a2977..d0e153f 100644 --- a/apps/web/src/app/inbox/InboxThreadPane.tsx +++ b/apps/web/src/app/inbox/InboxThreadPane.tsx @@ -207,25 +207,19 @@ export function InboxThreadPane({ Insert Link - {/* */}
); } return ( - - // + ); }; diff --git a/apps/web/src/app/inbox/hooks/useInboxConvex.ts b/apps/web/src/app/inbox/hooks/useInboxConvex.ts index 789264a..4c6a416 100644 --- a/apps/web/src/app/inbox/hooks/useInboxConvex.ts +++ b/apps/web/src/app/inbox/hooks/useInboxConvex.ts @@ -190,22 +190,24 @@ export function useInboxConvex({ } let cancelled = false; - - searchKnowledge({ workspaceId, query: knowledgeSearch, limit: 20 }) - .then((results) => { - if (!cancelled) { - setKnowledgeResults(results); - } - }) - .catch((error) => { - console.error("Knowledge search failed:", error); - if (!cancelled) { - setKnowledgeResults(undefined); - } - }); + 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]); diff --git a/apps/web/src/app/inbox/page.tsx b/apps/web/src/app/inbox/page.tsx index 3f31710..872315e 100644 --- a/apps/web/src/app/inbox/page.tsx +++ b/apps/web/src/app/inbox/page.tsx @@ -384,7 +384,7 @@ function InboxContent(): React.JSX.Element | null { if (item.type === "snippet") { setInputValue((prev) => `${prev}${prev ? "\n\n" : ""}${item.content}`); setLastInsertedSnippetId(item.id as Id<"snippets">); - } else if (action === "link" && (item.type === "article" || item.type === "internalArticle")) { + } else if (action === "link" && item.type === "article") { setInputValue((prev) => `${prev}${prev ? "\n\n" : ""}[${item.title}](article:${item.id})`); } else { setInputValue((prev) => `${prev}${prev ? "\n\n" : ""}${item.content}`); diff --git a/packages/convex/convex/knowledge.ts b/packages/convex/convex/knowledge.ts index 1ae43c0..23ef5c7 100644 --- a/packages/convex/convex/knowledge.ts +++ b/packages/convex/convex/knowledge.ts @@ -255,8 +255,6 @@ const GET_EMBEDDING_BY_ID_REF: EmbeddingQueryRef< type ContentRecord = { content: string; title: string; - slug?: string; - tags?: string[]; } | null; const GET_CONTENT_BY_ID_REF: EmbeddingQueryRef< @@ -301,7 +299,7 @@ export const searchWithEmbeddings = authAction({ value: args.query, }); - const results = await ctx.vectorSearch("contentEmbeddings", "by_embedding", { + const vectorResults = await ctx.vectorSearch("contentEmbeddings", "by_embedding", { vector: embedding, limit: limit * 8, filter: (q) => q.eq("workspaceId", args.workspaceId), @@ -310,55 +308,71 @@ export const searchWithEmbeddings = authAction({ const contentTypeSet = args.contentTypes && args.contentTypes.length > 0 ? new Set(args.contentTypes) : null; - const enrichedResults: (KnowledgeSearchResult | null)[] = await Promise.all( - results.map( - async (result: { - _id: Id<"contentEmbeddings">; - _score: number; - }): Promise => { - const doc = await runQuery(GET_EMBEDDING_BY_ID_REF, { - id: result._id, - }); - if (!doc) return null; - if (contentTypeSet && !contentTypeSet.has(doc.contentType)) return null; - - const contentRecord = await runQuery(GET_CONTENT_BY_ID_REF, { - contentType: doc.contentType, - contentId: doc.contentId, - }); - if (!contentRecord) return null; - - return { - id: doc.contentId, - type: doc.contentType, - title: doc.title, - content: contentRecord.content, - snippet: doc.snippet, - slug: contentRecord.slug, - tags: contentRecord.tags, - relevanceScore: result._score, - updatedAt: doc.updatedAt, - }; - } + const embeddingDocs = await Promise.all( + vectorResults.map((result) => + runQuery(GET_EMBEDDING_BY_ID_REF, { id: result._id }).then((doc) => + doc ? { ...doc, _score: result._score } : null + ) ) ); - const filtered = enrichedResults.filter((r): r is KnowledgeSearchResult => r !== null); - const deduped: KnowledgeSearchResult[] = []; const seen = new Set(); - for (const result of filtered) { - const key = `${result.type}:${result.id}`; - if (seen.has(key)) { - continue; - } + 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); - deduped.push(result); - if (deduped.length >= limit) { - break; - } + + 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; } - return deduped; + const contentRecords = await Promise.all( + dedupedEmbeddingDocs.map((doc) => + runQuery(GET_CONTENT_BY_ID_REF, { + 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, + relevanceScore: doc._score, + updatedAt: doc.updatedAt, + }); + } + + return results; }, }); diff --git a/packages/web-shared/src/markdown.test.ts b/packages/web-shared/src/markdown.test.ts index 2272e71..f2d234f 100644 --- a/packages/web-shared/src/markdown.test.ts +++ b/packages/web-shared/src/markdown.test.ts @@ -37,7 +37,7 @@ describe("parseMarkdown", () => { 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).not.toContain("href="); + expect(html).toContain('href="article:k57f8d9g2h3j4k5l"'); expect(html).not.toContain("target="); expect(html).not.toContain("rel="); }); @@ -52,6 +52,12 @@ describe("parseMarkdown", () => { 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 f82ab52..8760228 100644 --- a/packages/web-shared/src/markdown.ts +++ b/packages/web-shared/src/markdown.ts @@ -77,7 +77,7 @@ function hasDisallowedAbsoluteProtocol(rawUrl: string): boolean { return false; } const protocol = match[1].toLowerCase(); - return protocol !== "http" && protocol !== "https" && protocol !== "article"; + return protocol !== "http" && protocol !== "https"; } function isArticleLink(href: string): boolean { @@ -107,7 +107,7 @@ function enforceSafeLinksAndMedia(html: string, options: ResolvedParseMarkdownOp if (articleId) { anchor.setAttribute("data-article-id", articleId); anchor.setAttribute("class", "opencom-article-link"); - anchor.removeAttribute("href"); + anchor.setAttribute("href", `article:${articleId}`); anchor.removeAttribute("target"); anchor.removeAttribute("rel"); } else { From b4f513681aaab28d17da165d8c96b2f71ad02456 Mon Sep 17 00:00:00 2001 From: Jack D Date: Fri, 13 Mar 2026 23:08:57 +0000 Subject: [PATCH 4/5] Cover edge cases --- ROADMAP.md | 2 +- apps/web/src/app/inbox/InboxThreadPane.tsx | 12 +++++------- apps/web/src/app/inbox/page.tsx | 2 +- .../src/components/conversationView/MessageList.tsx | 11 +++++++++-- 4 files changed, 16 insertions(+), 11 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 5090218..7e50e36 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -50,7 +50,7 @@ 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) apps/web/src/app/outbound/[id]/OutboundTriggerPanel.tsx Comment on lines +67 to 71 diff --git a/apps/web/src/app/inbox/InboxThreadPane.tsx b/apps/web/src/app/inbox/InboxThreadPane.tsx index d0e153f..cd7e3a2 100644 --- a/apps/web/src/app/inbox/InboxThreadPane.tsx +++ b/apps/web/src/app/inbox/InboxThreadPane.tsx @@ -207,18 +207,16 @@ export function InboxThreadPane({ Insert Link +
); } return ( - ); }; diff --git a/apps/web/src/app/inbox/page.tsx b/apps/web/src/app/inbox/page.tsx index 872315e..9145ebd 100644 --- a/apps/web/src/app/inbox/page.tsx +++ b/apps/web/src/app/inbox/page.tsx @@ -384,7 +384,7 @@ function InboxContent(): React.JSX.Element | null { if (item.type === "snippet") { setInputValue((prev) => `${prev}${prev ? "\n\n" : ""}${item.content}`); setLastInsertedSnippetId(item.id as Id<"snippets">); - } else if (action === "link" && item.type === "article") { + } else if (action === "link" && item.type === "article" && 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/widget/src/components/conversationView/MessageList.tsx b/apps/widget/src/components/conversationView/MessageList.tsx index 5e4763e..f64e35f 100644 --- a/apps/widget/src/components/conversationView/MessageList.tsx +++ b/apps/widget/src/components/conversationView/MessageList.tsx @@ -72,8 +72,15 @@ export function ConversationMessageList({ messagesEndRef, }: ConversationMessageListProps) { const handleMessageClick = (event: React.MouseEvent) => { - const target = event.target as HTMLElement; - const articleLink = target.closest("[data-article-id]"); + const target = event.target; + if (!target) { + return; + } + const element = target instanceof Element ? target : (target as Text).parentElement; + if (!element) { + return; + } + const articleLink = element.closest("[data-article-id]"); if (articleLink) { event.preventDefault(); event.stopPropagation(); From 9566e41612121015f7a3820af5a7d9e486d2cdbe Mon Sep 17 00:00:00 2001 From: Jack D Date: Fri, 13 Mar 2026 23:42:57 +0000 Subject: [PATCH 5/5] Return slugs --- packages/convex/convex/knowledge.ts | 39 ++++++------------- packages/convex/convex/suggestions.ts | 24 ++++++++---- .../tests/runtimeTypeHardeningGuard.test.ts | 5 ++- 3 files changed, 30 insertions(+), 38 deletions(-) diff --git a/packages/convex/convex/knowledge.ts b/packages/convex/convex/knowledge.ts index 23ef5c7..54ed7f4 100644 --- a/packages/convex/convex/knowledge.ts +++ b/packages/convex/convex/knowledge.ts @@ -1,9 +1,9 @@ import { v } from "convex/values"; -import { makeFunctionReference, type FunctionReference } from "convex/server"; +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 { Doc } from "./_generated/dataModel"; import type { MutationCtx, QueryCtx } from "./_generated/server"; import { createAIClient } from "./lib/aiGateway"; import { @@ -238,34 +238,16 @@ export const search = authQuery({ type EmbeddingQueryRef, Return> = FunctionReference< "query", - "public", + "internal", Args, Return >; -const GET_EMBEDDING_BY_ID_REF: EmbeddingQueryRef< - { id: Id<"contentEmbeddings"> }, - Doc<"contentEmbeddings"> | null -> = makeFunctionReference< - "query", - { id: Id<"contentEmbeddings"> }, - Doc<"contentEmbeddings"> | null ->("suggestions:getEmbeddingById"); - -type ContentRecord = { - content: string; - title: string; -} | null; - -const GET_CONTENT_BY_ID_REF: EmbeddingQueryRef< - { contentType: KnowledgeContentType; contentId: string }, - ContentRecord -> = makeFunctionReference< - "query", - { contentType: KnowledgeContentType; contentId: string }, - ContentRecord ->("suggestions:getContentById"); - +// 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, @@ -310,7 +292,7 @@ export const searchWithEmbeddings = authAction({ const embeddingDocs = await Promise.all( vectorResults.map((result) => - runQuery(GET_EMBEDDING_BY_ID_REF, { id: result._id }).then((doc) => + runQuery(internal.suggestions.getEmbeddingById, { id: result._id }).then((doc) => doc ? { ...doc, _score: result._score } : null ) ) @@ -348,7 +330,7 @@ export const searchWithEmbeddings = authAction({ const contentRecords = await Promise.all( dedupedEmbeddingDocs.map((doc) => - runQuery(GET_CONTENT_BY_ID_REF, { + runQuery(internal.suggestions.getContentById, { contentType: doc.contentType, contentId: doc.contentId, }) @@ -367,6 +349,7 @@ export const searchWithEmbeddings = authAction({ title: doc.title, content: content.content, snippet: doc.snippet, + slug: content.slug, relevanceScore: doc._score, updatedAt: doc.updatedAt, }); diff --git a/packages/convex/convex/suggestions.ts b/packages/convex/convex/suggestions.ts index a40065b..60a5bb7 100644 --- a/packages/convex/convex/suggestions.ts +++ b/packages/convex/convex/suggestions.ts @@ -92,9 +92,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 +127,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 +232,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; @@ -628,7 +636,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( diff --git a/packages/convex/tests/runtimeTypeHardeningGuard.test.ts b/packages/convex/tests/runtimeTypeHardeningGuard.test.ts index 8407f77..1262033 100644 --- a/packages/convex/tests/runtimeTypeHardeningGuard.test.ts +++ b/packages/convex/tests/runtimeTypeHardeningGuard.test.ts @@ -189,9 +189,10 @@ describe("runtime type hardening guards", () => { ); expect(knowledgeSource).not.toContain("function getQueryRef(name: string)"); - expect(knowledgeSource).toContain("GET_EMBEDDING_BY_ID_REF"); - expect(knowledgeSource).toContain("GET_CONTENT_BY_ID_REF"); + 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", () => {