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" ? (
) : (
)}
{isEditable ? (
) : null}
)}
{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.label}
{removeAction}
{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 (
);
}