| author | |
| committer | |
| log | 11f40556de8a37c7f16486c23fb6bce058275557 |
| tree | 2a9fa318406a0ae2148c4bddd9ed385c0feaf33c |
| parent | 1bcfc88f589051c92882151788217c1410141ef7 |
| signature | Signed by PGP key B5690EEEBB952194 |
6 files changed, 134 insertions(+), 8 deletions(-)
apps/web/src/components/ChatMarkdown.tsx+18-1| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | import { DiffsHighlighter, getSharedHighlighter, SupportedLanguages } from "@pierre/diffs"; |
| 2 | 2 | import { CheckIcon, CopyIcon } from "lucide-react"; |
| 3 | import type { ServerProviderSkill } from "@t3tools/contracts"; | |
| 3 | 4 | import React, { |
| 4 | 5 | Children, |
| 5 | 6 | Suspense, |
| ... | ... | @@ -19,6 +20,7 @@ import ReactMarkdown from "react-markdown"; |
| 19 | 20 | import { defaultUrlTransform } from "react-markdown"; |
| 20 | 21 | import remarkGfm from "remark-gfm"; |
| 21 | 22 | import { VscodeEntryIcon } from "./chat/VscodeEntryIcon"; |
| 23 | import { renderSkillInlineMarkdownChildren } from "./chat/SkillInlineText"; | |
| 22 | 24 | import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; |
| 23 | 25 | import { stackedThreadToast, toastManager } from "./ui/toast"; |
| 24 | 26 | import { openInPreferredEditor } from "../editorPreferences"; |
| ... | ... | @@ -59,8 +61,11 @@ interface ChatMarkdownProps { |
| 59 | 61 | text: string; |
| 60 | 62 | cwd: string | undefined; |
| 61 | 63 | isStreaming?: boolean; |
| 64 | skills?: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>; | |
| 62 | 65 | } |
| 63 | 66 | |
| 67 | const EMPTY_MARKDOWN_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = []; | |
| 68 | ||
| 64 | 69 | const CODE_FENCE_LANGUAGE_REGEX = /(?:^|\s)language-([^\s]+)/; |
| 65 | 70 | const MAX_HIGHLIGHT_CACHE_ENTRIES = 500; |
| 66 | 71 | const MAX_HIGHLIGHT_CACHE_MEMORY_BYTES = 50 * 1024 * 1024; |
| ... | ... | @@ -507,7 +512,12 @@ function areMarkdownFileLinkPropsEqual( |
| 507 | 512 | ); |
| 508 | 513 | } |
| 509 | 514 | |
| 510 | function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { | |
| 515 | function ChatMarkdown({ | |
| 516 | text, | |
| 517 | cwd, | |
| 518 | isStreaming = false, | |
| 519 | skills = EMPTY_MARKDOWN_SKILLS, | |
| 520 | }: ChatMarkdownProps) { | |
| 511 | 521 | const { resolvedTheme } = useTheme(); |
| 512 | 522 | const diffThemeName = resolveDiffThemeName(resolvedTheme); |
| 513 | 523 | const markdownFileLinkMetaByHref = useMemo(() => { |
| ... | ... | @@ -534,6 +544,12 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { |
| 534 | 544 | }, []); |
| 535 | 545 | const markdownComponents = useMemo<Components>( |
| 536 | 546 | () => ({ |
| 547 | p({ node: _node, children, ...props }) { | |
| 548 | return <p {...props}>{renderSkillInlineMarkdownChildren(children, skills)}</p>; | |
| 549 | }, | |
| 550 | li({ node: _node, children, ...props }) { | |
| 551 | return <li {...props}>{renderSkillInlineMarkdownChildren(children, skills)}</li>; | |
| 552 | }, | |
| 537 | 553 | a({ node: _node, href, ...props }) { |
| 538 | 554 | const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : ""; |
| 539 | 555 | const fileLinkMeta = normalizedHref ? markdownFileLinkMetaByHref.get(normalizedHref) : null; |
| ... | ... | @@ -592,6 +608,7 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { |
| 592 | 608 | isStreaming, |
| 593 | 609 | markdownFileLinkMetaByHref, |
| 594 | 610 | resolvedTheme, |
| 611 | skills, | |
| 595 | 612 | ], |
| 596 | 613 | ); |
| 597 | 614 |
apps/web/src/components/ChatView.tsx+2| ... | ... | @@ -195,6 +195,7 @@ const IMAGE_ONLY_BOOTSTRAP_PROMPT = |
| 195 | 195 | const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; |
| 196 | 196 | const EMPTY_PROPOSED_PLANS: Thread["proposedPlans"] = []; |
| 197 | 197 | const EMPTY_PROVIDERS: ServerProvider[] = []; |
| 198 | const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; | |
| 198 | 199 | const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {}; |
| 199 | 200 | type EnvironmentUnavailableState = { |
| 200 | 201 | readonly environmentId: EnvironmentId; |
| ... | ... | @@ -3574,6 +3575,7 @@ export default function ChatView(props: ChatViewProps) { |
| 3574 | 3575 | resolvedTheme={resolvedTheme} |
| 3575 | 3576 | timestampFormat={timestampFormat} |
| 3576 | 3577 | workspaceRoot={activeWorkspaceRoot} |
| 3578 | skills={activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS} | |
| 3577 | 3579 | onIsAtEndChange={onIsAtEndChange} |
| 3578 | 3580 | /> |
| 3579 | 3581 |
apps/web/src/components/ComposerPromptEditor.tsx+1-2| ... | ... | @@ -72,6 +72,7 @@ import { |
| 72 | 72 | COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, |
| 73 | 73 | COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME, |
| 74 | 74 | COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME, |
| 75 | SKILL_CHIP_ICON_SVG, | |
| 75 | 76 | } from "./composerInlineChip"; |
| 76 | 77 | import { ComposerPendingTerminalContextChip } from "./chat/ComposerPendingTerminalContexts"; |
| 77 | 78 | import { formatProviderSkillDisplayName } from "~/providerSkillPresentation"; |
| ... | ... | @@ -216,8 +217,6 @@ function $createComposerMentionNode(path: string): ComposerMentionNode { |
| 216 | 217 | return $applyNodeReplacement(new ComposerMentionNode(path)); |
| 217 | 218 | } |
| 218 | 219 | |
| 219 | const SKILL_CHIP_ICON_SVG = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.85" stroke-linecap="round" stroke-linejoin="round"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></svg>`; | |
| 220 | ||
| 221 | 220 | function resolveSkillDescription( |
| 222 | 221 | skill: Pick<ServerProviderSkill, "shortDescription" | "description">, |
| 223 | 222 | ): string | null { |
apps/web/src/components/chat/MessagesTimeline.tsx+24-5| ... | ... | @@ -1,4 +1,9 @@ |
| 1 | import { type EnvironmentId, type MessageId, type TurnId } from "@t3tools/contracts"; | |
| 1 | import { | |
| 2 | type EnvironmentId, | |
| 3 | type MessageId, | |
| 4 | type ServerProviderSkill, | |
| 5 | type TurnId, | |
| 6 | } from "@t3tools/contracts"; | |
| 2 | 7 | import { |
| 3 | 8 | createContext, |
| 4 | 9 | memo, |
| ... | ... | @@ -60,6 +65,7 @@ import { |
| 60 | 65 | formatInlineTerminalContextLabel, |
| 61 | 66 | textContainsInlineTerminalContextLabels, |
| 62 | 67 | } from "./userMessageTerminalContexts"; |
| 68 | import { SkillInlineText } from "./SkillInlineText"; | |
| 63 | 69 | import { formatWorkspaceRelativePath } from "../../filePathDisplay"; |
| 64 | 70 | |
| 65 | 71 | // --------------------------------------------------------------------------- |
| ... | ... | @@ -75,6 +81,7 @@ interface TimelineRowSharedState { |
| 75 | 81 | markdownCwd: string | undefined; |
| 76 | 82 | resolvedTheme: "light" | "dark"; |
| 77 | 83 | workspaceRoot: string | undefined; |
| 84 | skills: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>; | |
| 78 | 85 | activeThreadEnvironmentId: EnvironmentId; |
| 79 | 86 | onRevertUserMessage: (messageId: MessageId) => void; |
| 80 | 87 | onImageExpand: (preview: ExpandedImagePreview) => void; |
| ... | ... | @@ -93,6 +100,7 @@ const TimelineRowCtx = createContext<TimelineRowSharedState>(null!); |
| 93 | 100 | const TimelineRowActivityCtx = createContext<TimelineRowActivityState>(null!); |
| 94 | 101 | const TIMELINE_LIST_HEADER = <div className="h-3 sm:h-4" />; |
| 95 | 102 | const TIMELINE_LIST_FOOTER = <div className="h-3 sm:h-4" />; |
| 103 | const EMPTY_TIMELINE_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">> = []; | |
| 96 | 104 | |
| 97 | 105 | // --------------------------------------------------------------------------- |
| 98 | 106 | // Props (public API) |
| ... | ... | @@ -119,6 +127,7 @@ interface MessagesTimelineProps { |
| 119 | 127 | resolvedTheme: "light" | "dark"; |
| 120 | 128 | timestampFormat: TimestampFormat; |
| 121 | 129 | workspaceRoot: string | undefined; |
| 130 | skills?: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>; | |
| 122 | 131 | onIsAtEndChange: (isAtEnd: boolean) => void; |
| 123 | 132 | } |
| 124 | 133 | |
| ... | ... | @@ -147,6 +156,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 147 | 156 | resolvedTheme, |
| 148 | 157 | timestampFormat, |
| 149 | 158 | workspaceRoot, |
| 159 | skills = EMPTY_TIMELINE_SKILLS, | |
| 150 | 160 | onIsAtEndChange, |
| 151 | 161 | }: MessagesTimelineProps) { |
| 152 | 162 | const rawRows = useMemo( |
| ... | ... | @@ -202,6 +212,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 202 | 212 | markdownCwd, |
| 203 | 213 | resolvedTheme, |
| 204 | 214 | workspaceRoot, |
| 215 | skills, | |
| 205 | 216 | activeThreadEnvironmentId, |
| 206 | 217 | onRevertUserMessage, |
| 207 | 218 | onImageExpand, |
| ... | ... | @@ -213,6 +224,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 213 | 224 | markdownCwd, |
| 214 | 225 | resolvedTheme, |
| 215 | 226 | workspaceRoot, |
| 227 | skills, | |
| 216 | 228 | activeThreadEnvironmentId, |
| 217 | 229 | onRevertUserMessage, |
| 218 | 230 | onImageExpand, |
| ... | ... | @@ -357,6 +369,7 @@ function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" |
| 357 | 369 | <UserMessageBody |
| 358 | 370 | text={displayedUserMessage.visibleText} |
| 359 | 371 | terminalContexts={terminalContexts} |
| 372 | skills={ctx.skills} | |
| 360 | 373 | /> |
| 361 | 374 | )} |
| 362 | 375 | <div className="mt-1.5 flex items-center justify-end gap-2"> |
| ... | ... | @@ -405,6 +418,7 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess |
| 405 | 418 | text={messageText} |
| 406 | 419 | cwd={ctx.markdownCwd} |
| 407 | 420 | isStreaming={Boolean(row.message.streaming)} |
| 421 | skills={ctx.skills} | |
| 408 | 422 | /> |
| 409 | 423 | <AssistantChangedFilesSection |
| 410 | 424 | turnSummary={row.assistantTurnDiffSummary} |
| ... | ... | @@ -744,6 +758,7 @@ const UserMessageTerminalContextInlineLabel = memo( |
| 744 | 758 | const UserMessageBody = memo(function UserMessageBody(props: { |
| 745 | 759 | text: string; |
| 746 | 760 | terminalContexts: ParsedTerminalContextEntry[]; |
| 761 | skills: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>; | |
| 747 | 762 | }) { |
| 748 | 763 | if (props.terminalContexts.length > 0) { |
| 749 | 764 | const hasEmbeddedInlineLabels = textContainsInlineTerminalContextLabels( |
| ... | ... | @@ -766,7 +781,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { |
| 766 | 781 | if (matchIndex > cursor) { |
| 767 | 782 | inlineNodes.push( |
| 768 | 783 | <span key={`user-terminal-context-inline-before:${context.header}:${cursor}`}> |
| 769 | {props.text.slice(cursor, matchIndex)} | |
| 784 | <SkillInlineText text={props.text.slice(cursor, matchIndex)} skills={props.skills} /> | |
| 770 | 785 | </span>, |
| 771 | 786 | ); |
| 772 | 787 | } |
| ... | ... | @@ -783,7 +798,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { |
| 783 | 798 | if (cursor < props.text.length) { |
| 784 | 799 | inlineNodes.push( |
| 785 | 800 | <span key={`user-message-terminal-context-inline-rest:${cursor}`}> |
| 786 | {props.text.slice(cursor)} | |
| 801 | <SkillInlineText text={props.text.slice(cursor)} skills={props.skills} /> | |
| 787 | 802 | </span>, |
| 788 | 803 | ); |
| 789 | 804 | } |
| ... | ... | @@ -811,7 +826,11 @@ const UserMessageBody = memo(function UserMessageBody(props: { |
| 811 | 826 | } |
| 812 | 827 | |
| 813 | 828 | if (props.text.length > 0) { |
| 814 | inlineNodes.push(<span key="user-message-terminal-context-inline-text">{props.text}</span>); | |
| 829 | inlineNodes.push( | |
| 830 | <span key="user-message-terminal-context-inline-text"> | |
| 831 | <SkillInlineText text={props.text} skills={props.skills} /> | |
| 832 | </span>, | |
| 833 | ); | |
| 815 | 834 | } else if (inlinePrefix.length === 0) { |
| 816 | 835 | return null; |
| 817 | 836 | } |
| ... | ... | @@ -829,7 +848,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { |
| 829 | 848 | |
| 830 | 849 | return ( |
| 831 | 850 | <div className="whitespace-pre-wrap wrap-break-word text-sm leading-relaxed text-foreground"> |
| 832 | {props.text} | |
| 851 | <SkillInlineText text={props.text} skills={props.skills} /> | |
| 833 | 852 | </div> |
| 834 | 853 | ); |
| 835 | 854 | }); |
apps/web/src/components/chat/SkillInlineText.tsx created+87| ... | ... | @@ -0,0 +1,87 @@ |
| 1 | import { Children, cloneElement, isValidElement, type ReactNode } from "react"; | |
| 2 | import type { ServerProviderSkill } from "@t3tools/contracts"; | |
| 3 | ||
| 4 | import { formatProviderSkillDisplayName } from "../../providerSkillPresentation"; | |
| 5 | import { | |
| 6 | COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, | |
| 7 | COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME, | |
| 8 | COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME, | |
| 9 | SKILL_CHIP_ICON_SVG, | |
| 10 | } from "../composerInlineChip"; | |
| 11 | ||
| 12 | const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/g; | |
| 13 | ||
| 14 | type InlineSkill = Pick<ServerProviderSkill, "name" | "displayName">; | |
| 15 | ||
| 16 | export function SkillInlineText(props: { text: string; skills: ReadonlyArray<InlineSkill> }) { | |
| 17 | const nodes: ReactNode[] = []; | |
| 18 | let cursor = 0; | |
| 19 | ||
| 20 | for (const match of props.text.matchAll(SKILL_TOKEN_REGEX)) { | |
| 21 | const prefix = match[1] ?? ""; | |
| 22 | const name = match[2] ?? ""; | |
| 23 | const start = (match.index ?? 0) + prefix.length; | |
| 24 | const rawText = `$${name}`; | |
| 25 | const skill = props.skills.find((candidate) => candidate.name === name); | |
| 26 | if (!skill) { | |
| 27 | continue; | |
| 28 | } | |
| 29 | ||
| 30 | if (start > cursor) { | |
| 31 | nodes.push(props.text.slice(cursor, start)); | |
| 32 | } | |
| 33 | nodes.push(<SkillChip key={`${start}:${name}`} skill={skill} rawText={rawText} />); | |
| 34 | cursor = start + rawText.length; | |
| 35 | } | |
| 36 | ||
| 37 | if (cursor === 0) { | |
| 38 | return <>{props.text}</>; | |
| 39 | } | |
| 40 | if (cursor < props.text.length) { | |
| 41 | nodes.push(props.text.slice(cursor)); | |
| 42 | } | |
| 43 | return <>{nodes}</>; | |
| 44 | } | |
| 45 | ||
| 46 | export function renderSkillInlineMarkdownChildren( | |
| 47 | children: ReactNode, | |
| 48 | skills: ReadonlyArray<InlineSkill>, | |
| 49 | ): ReactNode { | |
| 50 | return Children.map(children, (child) => { | |
| 51 | if (typeof child === "string") { | |
| 52 | return <SkillInlineText text={child} skills={skills} />; | |
| 53 | } | |
| 54 | if (!isValidElement<{ children?: ReactNode }>(child)) { | |
| 55 | return child; | |
| 56 | } | |
| 57 | if (child.type === "code" || child.type === "a") { | |
| 58 | return child; | |
| 59 | } | |
| 60 | if (!("children" in child.props)) { | |
| 61 | return child; | |
| 62 | } | |
| 63 | return cloneElement( | |
| 64 | child, | |
| 65 | undefined, | |
| 66 | renderSkillInlineMarkdownChildren(child.props.children, skills), | |
| 67 | ); | |
| 68 | }); | |
| 69 | } | |
| 70 | ||
| 71 | function SkillChip(props: { skill: InlineSkill; rawText: string }) { | |
| 72 | return ( | |
| 73 | <span className="inline-flex align-middle leading-none"> | |
| 74 | <span className="sr-only">{props.rawText}</span> | |
| 75 | <span aria-hidden="true" className={COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME}> | |
| 76 | <span | |
| 77 | aria-hidden="true" | |
| 78 | className={COMPOSER_INLINE_CHIP_ICON_CLASS_NAME} | |
| 79 | dangerouslySetInnerHTML={{ __html: SKILL_CHIP_ICON_SVG }} | |
| 80 | /> | |
| 81 | <span className={COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME}> | |
| 82 | {formatProviderSkillDisplayName(props.skill)} | |
| 83 | </span> | |
| 84 | </span> | |
| 85 | </span> | |
| 86 | ); | |
| 87 | } |
apps/web/src/components/composerInlineChip.ts+2| ... | ... | @@ -8,5 +8,7 @@ export const COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME = "truncate select-none leadi |
| 8 | 8 | export const COMPOSER_INLINE_SKILL_CHIP_CLASS_NAME = |
| 9 | 9 | "inline-flex max-w-full select-none items-center gap-1 rounded-md border border-fuchsia-500/25 bg-fuchsia-500/12 px-1.5 py-px font-medium text-[12px] leading-[1.1] text-fuchsia-700 align-middle dark:text-fuchsia-300"; |
| 10 | 10 | |
| 11 | export const SKILL_CHIP_ICON_SVG = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.85" stroke-linecap="round" stroke-linejoin="round"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></svg>`; | |
| 12 | ||
| 11 | 13 | export const COMPOSER_INLINE_CHIP_DISMISS_BUTTON_CLASS_NAME = |
| 12 | 14 | "ml-0.5 inline-flex size-3.5 shrink-0 cursor-pointer items-center justify-center rounded-sm text-muted-foreground/72 transition-colors hover:bg-foreground/6 hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"; |