import { createContext, useContext, useEffect, useState, type ComponentPropsWithoutRef, type JSX, type MouseEvent, type MouseEventHandler, type ReactNode } from "react"; import { NodeViewWrapper, type NodeViewProps } from "@tiptap/react"; import { useResolvedRichTextMention, useRichTextMentionService } from "@tutti-os/ui-rich-text/editor"; import type { RichTextMentionIdentity } from "@tutti-os/ui-rich-text/types"; import { MentionPill } from "@tutti-os/ui-system/components"; import { Spinner } from "@tutti-os/ui-system"; import { CloseIcon } from "@tutti-os/ui-system/icons"; import { useTranslation } from "../../../i18n/index"; import { resolveAgentMentionFileThumbnailUrl, resolveAgentMentionFileVisualKind } from "../../shared/mentionFilePresentation"; import { getAgentCustomMentionKind } from "../../../shared/agentCustomMentionKinds"; import { managedAgentRoundedIconUrl } from "../../../shared/managedAgentIcons"; import { useAgentTargetPresentations } from "../../../shared/AgentTargetPresentationContext"; import { resolveAgentMentionTargetPresentation, type AgentMessageMarkdownAgentTarget } from "../../../shared/agentTargetPresentation"; import { parseResolvableAgentMentionIdentity, resolveAgentMentionNodePresentation, type AgentMentionResolvedPresentation } from "./agentMentionNodeResolution"; import { AGENT_RICH_TEXT_CARET_ANCHOR } from "./agentRichTextCaretAnchor"; import { agentExternalPromptFileErrorI18nKey } from "../model/agentExternalPromptFiles"; import { dirnameFromPath } from "./agentMentionMarkdown"; type AgentMentionNodeViewKind = | "file" | "agent-target" | "session" | "workspace-app" | "workspace-reference" | "workspace-app-factory" | "workspace-issue" | "custom"; function parseFileCountAttr(value: unknown): number { const parsed = typeof value === "string" ? Number.parseInt(value.trim(), 10) : Number.NaN; return Number.isFinite(parsed) && parsed > 0 ? parsed : 0; } interface AgentMentionNodeViewModel { attachmentStatus?: "uploading" | "ready" | "error"; attachmentErrorLabel?: string; ariaLabel: string; /** 宿主注册的自定义 mention kind(kind === "custom" 专用)。 */ customKind?: string; directoryPath: string; entryKind: string; href: string; iconUrl?: string; kind: AgentMentionNodeViewKind; label: string; summary?: string; thumbnailUrl?: string; /** 引用文件数量(workspace-reference 专用)。 */ fileCount?: number; } type AgentMentionWrapperProps = ComponentPropsWithoutRef<"span">; type AgentMentionWrapper = (props: AgentMentionWrapperProps) => JSX.Element; function AgentMentionNodeWrapper(props: AgentMentionWrapperProps): JSX.Element { return ; } function AgentMentionStaticWrapper( props: AgentMentionWrapperProps ): JSX.Element { return ; } const AgentMentionTooltipProviderContext = createContext(true); export function AgentMentionTooltipProviderScope({ children, withTooltipProvider }: { children: ReactNode; withTooltipProvider: boolean; }): JSX.Element { return ( {children} ); } function attrString(attrs: Record, key: string): string { const value = attrs[key]; return typeof value === "string" ? value : ""; } function normalizeKind(value: string): AgentMentionNodeViewKind { if (value === "session" || value === "agent-session") { return "session"; } if (value === "workspace-issue") { return "workspace-issue"; } if (value === "workspace-app") { return "workspace-app"; } if (value === "workspace-reference") { return "workspace-reference"; } if (value === "workspace-app-factory") { return "workspace-app-factory"; } if (value === "agent-target") { return "agent-target"; } if (value === "custom") { return "custom"; } return "file"; } function mentionViewModel( attrs: Record, t: (key: string) => string, agentTargets: readonly AgentMessageMarkdownAgentTarget[], resolvedPresentation?: AgentMentionResolvedPresentation ): AgentMentionNodeViewModel { const kind = normalizeKind(attrString(attrs, "kind")); const name = attrString(attrs, "name"); const displayName = resolvedPresentation?.label ?? name; const href = attrString(attrs, "href"); if (kind === "session") { const label = attrString(attrs, "title").trim() || name.trim(); const agentTargetId = attrString(attrs, "agentTargetId").trim(); const presentation = resolveAgentMentionTargetPresentation({ agentTargetId, agentTargets, fallbackIconUrl: attrString(attrs, "iconUrl"), workspaceId: attrString(attrs, "workspaceId") }); return { ariaLabel: `${t("agentHost.agentGui.mentionKindSession")} ${label}`.trim(), directoryPath: "", entryKind: "", href, iconUrl: presentation.iconUrl, kind, label }; } if (kind === "workspace-issue") { return { ariaLabel: `${t("agentHost.agentGui.mentionKindIssue")} ${displayName}`.trim(), directoryPath: "", entryKind: "", href, iconUrl: resolvedPresentation?.iconUrl ?? (resolvedPresentation ? undefined : attrString(attrs, "iconUrl").trim() || undefined), kind, label: displayName }; } if (kind === "workspace-app") { return { ariaLabel: `${t("agentHost.agentGui.mentionKindApp")} ${displayName}`.trim(), directoryPath: "", entryKind: "", href, iconUrl: resolvedPresentation?.iconUrl ?? (resolvedPresentation ? undefined : attrString(attrs, "iconUrl").trim() || undefined), kind, label: displayName }; } if (kind === "workspace-reference") { return { ariaLabel: `${t("agentHost.agentGui.mentionKindApp")} ${displayName}`.trim(), directoryPath: "", entryKind: "", href, iconUrl: attrString(attrs, "iconUrl").trim() || undefined, kind, label: displayName, fileCount: parseFileCountAttr(attrs.fileCount) }; } if (kind === "workspace-app-factory") { return { ariaLabel: `${t("agentHost.agentGui.mentionKindAppFactory")} ${displayName}`.trim(), directoryPath: "", entryKind: "", href, kind, label: displayName }; } if (kind === "agent-target") { const agentProviderId = resolvedPresentation?.agentProviderId ?? (resolvedPresentation ? "" : attrString(attrs, "agentProviderId").trim()); const presentation = resolveAgentMentionTargetPresentation({ agentTargetId: attrString(attrs, "targetId"), agentTargets, fallbackIconUrl: resolvedPresentation?.iconUrl || (!resolvedPresentation && attrString(attrs, "iconUrl").trim()) || managedAgentRoundedIconUrl(agentProviderId || undefined), fallbackName: displayName, fallbackProvider: agentProviderId, workspaceId: attrString(attrs, "workspaceId") }); return { ariaLabel: `${t("agentHost.agentGui.mentionKindAgent")} ${presentation.name ?? displayName}`.trim(), directoryPath: "", entryKind: "", href, iconUrl: presentation.iconUrl, kind, label: presentation.name ?? displayName }; } if (kind === "custom") { return { ariaLabel: `${t("agentHost.agentGui.mentionKindReference")} ${displayName}`.trim(), customKind: attrString(attrs, "customKind").trim(), directoryPath: "", entryKind: "", href, kind, label: displayName, summary: attrString(attrs, "preview").trim() || undefined }; } const path = attrString(attrs, "path") || href; const entryKind = attrString(attrs, "entryKind") || "unknown"; return { attachmentStatus: attrString(attrs, "attachmentStatus") === "uploading" || attrString(attrs, "attachmentStatus") === "error" ? (attrString(attrs, "attachmentStatus") as "uploading" | "error") : attrString(attrs, "attachmentId") ? "ready" : undefined, attachmentErrorLabel: attrString(attrs, "attachmentStatus") === "error" ? t( agentExternalPromptFileErrorI18nKey( attrString(attrs, "attachmentErrorCode") ) ) : undefined, ariaLabel: name, directoryPath: attrString(attrs, "directoryPath") || dirnameFromPath(path), entryKind, href: href || path, kind, label: name, thumbnailUrl: resolveAgentMentionFileThumbnailUrl({ entryKind, href: href || path, name, path, thumbnailUrl: attrString(attrs, "thumbnailUrl") }) }; } function fileVisualKind(entryKind: string, path: string): string { return resolveAgentMentionFileVisualKind({ entryKind, path }); } function hasPromptContentAfterMentionRemoval( doc: NodeViewProps["editor"]["state"]["doc"] ): boolean { let hasContent = false; doc.descendants((node) => { if (hasContent) { return false; } if (node.type.name === "agentFileMention") { hasContent = true; return false; } if ( node.isText && node.textContent.replaceAll(AGENT_RICH_TEXT_CARET_ANCHOR, "").trim() .length > 0 ) { hasContent = true; return false; } return true; }); return hasContent; } function AgentMentionLegacyFileView({ isEditable, mention, onRemove, removeActionAriaLabel, selected, Wrapper }: { isEditable: boolean; mention: AgentMentionNodeViewModel; onRemove?: MouseEventHandler; removeActionAriaLabel?: string; selected: boolean; Wrapper: AgentMentionWrapper; }): JSX.Element { return ( {mention.thumbnailUrl ? ( {isEditable ? ( ) : null} ) : ( {mention.attachmentStatus === "uploading" ? ( ) : ( )} {mention.label} ); } interface AgentMentionViewProps { agentTargets?: readonly AgentMessageMarkdownAgentTarget[]; attrs: Record; isEditable: boolean; onRemove?: MouseEventHandler; removeActionAriaLabel?: string; selected: boolean; Wrapper: AgentMentionWrapper; } function AgentMentionView(props: AgentMentionViewProps): JSX.Element { const identity = parseResolvableAgentMentionIdentity( props.attrs, normalizeKind(attrString(props.attrs, "kind")) ); return identity ? ( ) : ( ); } function AgentMentionResolvedView({ identity, ...props }: AgentMentionViewProps & { identity: RichTextMentionIdentity; }): JSX.Element { const mentionService = useRichTextMentionService(); const snapshot = useResolvedRichTextMention(identity); const resolvedPresentation = resolveAgentMentionNodePresentation({ attrs: props.attrs, hasMentionService: mentionService !== null, resolved: snapshot.state === "ready" ? snapshot.resolved : undefined, state: snapshot.state }); return ( ); } function AgentMentionPresentationView({ agentTargets, attrs, isEditable, onRemove, removeActionAriaLabel, resolvedPresentation, selected, Wrapper }: AgentMentionViewProps & { resolvedPresentation?: AgentMentionResolvedPresentation; }): JSX.Element { const { t } = useTranslation(); const withTooltipProvider = useContext(AgentMentionTooltipProviderContext); const contextAgentTargets = useAgentTargetPresentations(); const mention = mentionViewModel( attrs, t, agentTargets ?? contextAgentTargets, resolvedPresentation ); if (mention.kind === "file") { return ( ); } if ( mention.kind === "workspace-app" || mention.kind === "workspace-reference" || mention.kind === "agent-target" ) { return ( ); } if (mention.kind === "custom") { // 宿主注册的自定义 mention:优先用注册的 renderChip,缺省用通用双行卡 // (第一行 name,第二行 summary)。点击经 rich-text surface 的 link click 委托 // (data-agent-mention-href)上抛 onLinkAction,由宿主二次解析 href。 const definition = getAgentCustomMentionKind(mention.customKind ?? ""); const removeAction = isEditable ? ( ) : null; if (definition?.renderChip) { return ( {definition.renderChip({ href: mention.href, name: mention.label, summary: mention.summary, isEditable, removeAction })} ); } return ( {mention.summary ? ( {mention.summary} ) : null} ); } return ( ); } export function AgentMentionReadonlyView({ agentTargets, attrs }: { agentTargets?: readonly AgentMessageMarkdownAgentTarget[]; attrs: Record; }): JSX.Element { return ( ); } export function AgentMentionNodeView({ deleteNode, editor, extension, node, selected }: NodeViewProps): JSX.Element { const [isEditable, setIsEditable] = useState(editor.isEditable); const extensionOptions = extension.options as { removeActionAriaLabel?: string; }; const removeActionAriaLabel = typeof extensionOptions.removeActionAriaLabel === "string" ? extensionOptions.removeActionAriaLabel : undefined; useEffect(() => { const syncEditable = () => { setIsEditable(editor.isEditable); }; syncEditable(); editor.on("transaction", syncEditable); editor.on("update", syncEditable); return () => { editor.off("transaction", syncEditable); editor.off("update", syncEditable); }; }, [editor]); const handleRemove = (event: MouseEvent) => { event.preventDefault(); event.stopPropagation(); if (!editor.isEditable) { return; } deleteNode(); if (!hasPromptContentAfterMentionRemoval(editor.state.doc)) { editor.commands.clearContent(); } }; return ( ); }