Files
tutti/packages/agent/gui/shared/AgentRichTextReadonly.tsx
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

222 lines
7.0 KiB
TypeScript

import { useMemo, type JSX, type MouseEvent } from "react";
import type { JSONContent } from "@tiptap/core";
import { renderToReactElement } from "@tiptap/static-renderer";
import { cn } from "../app/renderer/lib/utils";
import { plainTextToAgentRichTextDoc } from "../agent-gui/agentGuiNode/agentRichText/agentRichTextDocument";
import { AGENT_RICH_TEXT_CARET_ANCHOR } from "../agent-gui/agentGuiNode/agentRichText/agentRichTextCaretAnchor";
import { createAgentRichTextReadonlyExtensions } from "../agent-gui/agentGuiNode/agentRichText/agentRichTextExtensions";
import { AgentMentionReadonlyView } from "../agent-gui/agentGuiNode/agentRichText/AgentMentionNodeView";
import type { AgentMessageMarkdownWorkspaceAppIcon } from "./AgentMessageMarkdown";
import type { AgentGUIProviderSkillOption } from "../agent-gui/agentGuiNode/model/agentGuiNodeTypes";
import {
useAgentTargetPresentations,
type AgentMessageMarkdownAgentTarget
} from "./AgentTargetPresentationContext";
import { readParsedDocumentCache } from "./parsedDocumentCache";
const EMPTY_WORKSPACE_APP_ICONS: readonly AgentMessageMarkdownWorkspaceAppIcon[] =
[];
const EMPTY_SKILLS: readonly AgentGUIProviderSkillOption[] = [];
interface AgentRichTextReadonlyProps {
value: string;
documentCacheKey?: string;
className?: string;
editorClassName?: string;
onLinkClick?: (href: string) => void;
availableSkills?: readonly AgentGUIProviderSkillOption[];
workspaceAppIcons?: readonly AgentMessageMarkdownWorkspaceAppIcon[];
agentTargets?: readonly AgentMessageMarkdownAgentTarget[];
}
export function AgentRichTextReadonly({
value,
documentCacheKey,
className,
editorClassName,
onLinkClick,
availableSkills = EMPTY_SKILLS,
workspaceAppIcons = EMPTY_WORKSPACE_APP_ICONS,
agentTargets
}: AgentRichTextReadonlyProps): JSX.Element {
"use memo";
const contextAgentTargets = useAgentTargetPresentations();
const effectiveAgentTargets = agentTargets ?? contextAgentTargets;
const skillsCacheSource = useMemo(
() => JSON.stringify(availableSkills),
[availableSkills]
);
const parsedDoc = useMemo(
() =>
readParsedDocumentCache({
namespace: "rich-text",
identity: documentCacheKey?.trim() || "content",
source: `${value}\u0000${skillsCacheSource}`,
create: () =>
plainTextToAgentRichTextDoc(value, { skills: availableSkills })
}),
[availableSkills, documentCacheKey, skillsCacheSource, value]
);
const contentDoc = useMemo(
() =>
workspaceAppIcons.length > 0
? hydrateWorkspaceAppMentionIcons(parsedDoc, workspaceAppIcons)
: parsedDoc,
[parsedDoc, workspaceAppIcons]
);
const isMentionOnly = isMentionOnlyRichTextDoc(contentDoc);
const extensions = useMemo(
() =>
createAgentRichTextReadonlyExtensions({
skills: availableSkills
}),
[availableSkills]
);
const renderedContent = useMemo(
() =>
renderToReactElement({
content: contentDoc,
extensions,
options: {
nodeMapping: {
agentFileMention: ({ node }) => (
<AgentMentionReadonlyView
agentTargets={effectiveAgentTargets}
attrs={node.attrs ?? {}}
/>
)
}
}
}),
[contentDoc, effectiveAgentTargets, extensions]
);
const handleClick = (event: MouseEvent<HTMLDivElement>) => {
if (!onLinkClick || !(event.target instanceof Element)) {
return;
}
const mention = event.target.closest(
'[data-agent-file-mention="true"][data-agent-mention-href]'
);
if (!(mention instanceof HTMLElement)) {
return;
}
const href = mention.getAttribute("data-agent-mention-href") || "";
if (!href) {
return;
}
event.preventDefault();
event.stopPropagation();
onLinkClick(href);
};
return (
<div
className={className}
data-agent-mention-only={isMentionOnly ? "true" : undefined}
>
<div
className={cn(
"tiptap ProseMirror",
editorClassName,
"max-w-full overflow-x-hidden overflow-y-auto whitespace-pre-wrap break-words [overflow-wrap:anywhere] [&_p]:m-0 [&_p]:min-h-[1.45em] [&_a[data-agent-file-mention=true]]:cursor-pointer [&_[data-agent-file-mention=true]]:overflow-hidden"
)}
onClick={handleClick}
>
{renderedContent}
</div>
</div>
);
}
function isMentionOnlyRichTextDoc(doc: JSONContent): boolean {
if (doc.type !== "doc") {
return false;
}
const blocks = doc.content ?? [];
if (blocks.length !== 1) {
return false;
}
const paragraph = blocks[0];
if (paragraph?.type !== "paragraph") {
return false;
}
const inlineContent = (paragraph.content ?? []).filter(
(node) =>
!(
node.type === "text" &&
(node.text ?? "").replaceAll(AGENT_RICH_TEXT_CARET_ANCHOR, "")
.length === 0
)
);
return (
inlineContent.length === 1 && inlineContent[0]?.type === "agentFileMention"
);
}
function hydrateWorkspaceAppMentionIcons(
node: JSONContent,
workspaceAppIcons: readonly AgentMessageMarkdownWorkspaceAppIcon[]
): JSONContent {
const nextContent = node.content?.map((child) =>
hydrateWorkspaceAppMentionIcons(child, workspaceAppIcons)
);
if (node.type !== "agentFileMention") {
return nextContent ? { ...node, content: nextContent } : node;
}
const attrs = node.attrs ?? {};
const kind = typeof attrs.kind === "string" ? attrs.kind : "";
const isWorkspaceAppMention = kind === "workspace-app";
const isAppWorkspaceReferenceMention =
kind === "workspace-reference" && attrs.source === "app";
if (!isWorkspaceAppMention && !isAppWorkspaceReferenceMention) {
return nextContent ? { ...node, content: nextContent } : node;
}
const workspaceId =
typeof attrs.workspaceId === "string" ? attrs.workspaceId.trim() : "";
const appId =
isWorkspaceAppMention && typeof attrs.appId === "string"
? attrs.appId.trim()
: typeof attrs.targetId === "string"
? attrs.targetId.trim()
: "";
const iconUrl = resolveWorkspaceAppIconUrl({
appId,
workspaceId,
workspaceAppIcons
});
if (!iconUrl) {
return nextContent ? { ...node, content: nextContent } : node;
}
return {
...node,
attrs: {
...node.attrs,
iconUrl
},
...(nextContent ? { content: nextContent } : {})
};
}
function resolveWorkspaceAppIconUrl(input: {
appId: string;
workspaceId: string;
workspaceAppIcons: readonly AgentMessageMarkdownWorkspaceAppIcon[];
}): string | undefined {
if (!input.appId) {
return undefined;
}
const exactMatch = input.workspaceAppIcons.find(
(icon) =>
icon.appId.trim() === input.appId &&
(icon.workspaceId?.trim() ?? "") === input.workspaceId &&
icon.iconUrl?.trim()
);
const fallbackMatch = input.workspaceAppIcons.find(
(icon) => icon.appId.trim() === input.appId && icon.iconUrl?.trim()
);
return (
exactMatch?.iconUrl?.trim() || fallbackMatch?.iconUrl?.trim() || undefined
);
}