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/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/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/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/widget/src/components/conversationView/MessageList.tsx b/apps/widget/src/components/conversationView/MessageList.tsx index 7d39dd2..f64e35f 100644 --- a/apps/widget/src/components/conversationView/MessageList.tsx +++ b/apps/widget/src/components/conversationView/MessageList.tsx @@ -71,6 +71,26 @@ export function ConversationMessageList({ renderedMessages, messagesEndRef, }: ConversationMessageListProps) { + const handleMessageClick = (event: React.MouseEvent) => { + 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(); + const articleId = articleLink.getAttribute("data-article-id"); + if (articleId) { + onSelectArticle(articleId as Id<"articles">); + } + } + }; + return (
{!messages || messages.length === 0 ? ( @@ -102,7 +122,9 @@ export function ConversationMessageList({ return (
- {showTimestamp &&
{formatTime(msg._creationTime)}
} + {showTimestamp && ( +
{formatTime(msg._creationTime)}
+ )}
)} {isHumanAgent && ( - + {humanAgentName} )} @@ -125,6 +150,7 @@ export function ConversationMessageList({ dangerouslySetInnerHTML={{ __html: renderedMessages.get(msg._id) ?? "", }} + onClick={handleMessageClick} /> )} {msg.attachments && msg.attachments.length > 0 && ( @@ -150,7 +176,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..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/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 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/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/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." } ] }