Files
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

756 lines
23 KiB
TypeScript

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 <NodeViewWrapper as="span" {...props} />;
}
function AgentMentionStaticWrapper(
props: AgentMentionWrapperProps
): JSX.Element {
return <span {...props} />;
}
const AgentMentionTooltipProviderContext = createContext(true);
export function AgentMentionTooltipProviderScope({
children,
withTooltipProvider
}: {
children: ReactNode;
withTooltipProvider: boolean;
}): JSX.Element {
return (
<AgentMentionTooltipProviderContext value={withTooltipProvider}>
{children}
</AgentMentionTooltipProviderContext>
);
}
function attrString(attrs: Record<string, unknown>, 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<string, unknown>,
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<HTMLButtonElement>;
removeActionAriaLabel?: string;
selected: boolean;
Wrapper: AgentMentionWrapper;
}): JSX.Element {
return (
<Wrapper
aria-label={
mention.attachmentErrorLabel
? `${mention.ariaLabel}, ${mention.attachmentErrorLabel}`
: mention.ariaLabel
}
className={`agent-rich-text-mention-node group tsh-agent-object-token tsh-agent-object-token--file ${
selected ? "is-selected" : ""
}`}
contentEditable={false}
data-agent-file-directory-path={mention.directoryPath}
data-agent-file-entry-kind={mention.entryKind}
data-agent-file-mention="true"
{...(mention.thumbnailUrl
? {}
: {
"data-agent-file-visual-kind": fileVisualKind(
mention.entryKind,
mention.href || mention.label
)
})}
data-agent-mention-href={mention.href}
data-agent-mention-kind={mention.kind}
data-uploading={
mention.attachmentStatus === "uploading" ? "true" : undefined
}
data-upload-error={
mention.attachmentStatus === "error" ? "true" : undefined
}
title={mention.attachmentErrorLabel ?? undefined}
{...(mention.thumbnailUrl
? { "data-agent-mention-thumbnail-url": mention.thumbnailUrl }
: {})}
>
{mention.thumbnailUrl ? (
<span
className="agent-gui-node__mention-file-thumb relative"
data-agent-mention-file-thumb="true"
aria-hidden={isEditable ? undefined : true}
>
<img
src={mention.thumbnailUrl}
alt=""
className={`h-full w-full object-cover transition-opacity ${
isEditable
? "group-hover:opacity-0 group-focus-within:opacity-0"
: ""
}`}
decoding="async"
loading="lazy"
draggable={false}
/>
{isEditable ? (
<button
aria-label={removeActionAriaLabel}
className="absolute left-1/2 top-1/2 inline-flex size-5 -translate-x-1/2 -translate-y-1/2 items-center justify-center text-[var(--text-secondary)] opacity-0 transition-opacity hover:text-[var(--text-primary)] focus-visible:opacity-100 group-hover:opacity-100"
type="button"
onMouseDown={onRemove}
>
<CloseIcon className="size-3.5" />
</button>
) : null}
</span>
) : (
<span
className="relative grid size-4 shrink-0 place-items-center"
aria-hidden={isEditable ? undefined : true}
>
{mention.attachmentStatus === "uploading" ? (
<Spinner
className={
isEditable
? "transition-opacity group-hover:opacity-0 group-focus-within:opacity-0"
: undefined
}
size={14}
strokeWidth={2.4}
trackColor="var(--transparency-hover)"
testId="agent-gui-composer-file-upload-spinner"
/>
) : (
<span
className={`tsh-agent-object-token__icon transition-opacity ${
isEditable
? "group-hover:opacity-0 group-focus-within:opacity-0"
: ""
}`}
aria-hidden="true"
/>
)}
{isEditable ? (
<button
aria-label={removeActionAriaLabel}
className="absolute left-1/2 top-1/2 inline-flex size-5 -translate-x-1/2 -translate-y-1/2 items-center justify-center text-[var(--text-secondary)] opacity-0 transition-opacity hover:text-[var(--text-primary)] focus-visible:opacity-100 group-hover:opacity-100"
type="button"
onMouseDown={onRemove}
>
<CloseIcon className="size-3.5" />
</button>
) : null}
</span>
)}
<span className="tsh-agent-object-token__main">{mention.label}</span>
</Wrapper>
);
}
interface AgentMentionViewProps {
agentTargets?: readonly AgentMessageMarkdownAgentTarget[];
attrs: Record<string, unknown>;
isEditable: boolean;
onRemove?: MouseEventHandler<HTMLButtonElement>;
removeActionAriaLabel?: string;
selected: boolean;
Wrapper: AgentMentionWrapper;
}
function AgentMentionView(props: AgentMentionViewProps): JSX.Element {
const identity = parseResolvableAgentMentionIdentity(
props.attrs,
normalizeKind(attrString(props.attrs, "kind"))
);
return identity ? (
<AgentMentionResolvedView {...props} identity={identity} />
) : (
<AgentMentionPresentationView {...props} />
);
}
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 (
<AgentMentionPresentationView
{...props}
resolvedPresentation={resolvedPresentation}
/>
);
}
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 (
<AgentMentionLegacyFileView
isEditable={isEditable}
mention={mention}
onRemove={onRemove}
removeActionAriaLabel={removeActionAriaLabel}
selected={selected}
Wrapper={Wrapper}
/>
);
}
if (
mention.kind === "workspace-app" ||
mention.kind === "workspace-reference" ||
mention.kind === "agent-target"
) {
return (
<Wrapper
aria-label={mention.ariaLabel}
className={`agent-rich-text-mention-node inline-flex max-w-[min(100%,var(--agent-mention-max-width,16rem))] align-middle ${
selected ? "is-selected" : ""
}`}
contentEditable={false}
data-agent-file-mention="true"
data-agent-mention-href={mention.href}
data-agent-mention-icon-url={mention.iconUrl}
data-agent-mention-kind={mention.kind}
>
<MentionPill
aria-label={mention.ariaLabel}
className="top-0 h-6 max-w-[min(100%,var(--agent-mention-max-width,16rem))] py-0 align-middle leading-6"
data-agent-mention-kind={mention.kind}
iconUrl={mention.iconUrl}
iconContainerProps={{
"data-agent-mention-app-icon":
mention.kind === "agent-target" ? undefined : "true",
"data-agent-mention-session-icon":
mention.kind === "agent-target" ? "true" : undefined,
"data-workspace-app-icon":
mention.kind === "agent-target" ? undefined : "true"
}}
kind={mention.kind === "agent-target" ? "session" : "app"}
label={mention.label}
removable={isEditable}
removeButtonProps={
isEditable
? {
"aria-label": removeActionAriaLabel,
onMouseDown: onRemove
}
: undefined
}
withTooltipProvider={withTooltipProvider}
/>
</Wrapper>
);
}
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 ? (
<button
aria-label={removeActionAriaLabel}
className="inline-flex size-4 shrink-0 items-center justify-center text-[var(--text-secondary)] opacity-0 transition-opacity hover:text-[var(--text-primary)] focus-visible:opacity-100 group-hover:opacity-100"
type="button"
onMouseDown={onRemove}
>
<CloseIcon className="size-3.5" />
</button>
) : null;
if (definition?.renderChip) {
return (
<Wrapper
aria-label={mention.ariaLabel}
className={`agent-rich-text-mention-node inline-grid max-w-[min(100%,var(--agent-mention-max-width,20rem))] align-middle ${
selected ? "is-selected" : ""
}`}
contentEditable={false}
data-agent-custom-mention="true"
data-agent-file-mention="true"
data-agent-mention-href={mention.href}
data-agent-mention-kind={mention.customKind || mention.kind}
>
{definition.renderChip({
href: mention.href,
name: mention.label,
summary: mention.summary,
isEditable,
removeAction
})}
</Wrapper>
);
}
return (
<Wrapper
aria-label={mention.ariaLabel}
className={`agent-rich-text-mention-node inline-grid max-w-[min(100%,var(--agent-mention-max-width,20rem))] align-middle ${
selected ? "is-selected" : ""
}`}
contentEditable={false}
data-agent-custom-mention="true"
data-agent-file-mention="true"
data-agent-mention-href={mention.href}
data-agent-mention-kind={mention.customKind || mention.kind}
>
<span
className="group relative grid max-w-full cursor-pointer gap-0.5 overflow-hidden rounded-[8px] border border-[var(--border-primary,rgba(0,0,0,0.08))] bg-block px-2.5 py-1.5 text-left align-middle"
data-slot="mention-card"
>
<span className="flex min-w-0 items-center gap-1">
<span
aria-hidden="true"
className="tsh-agent-object-token__kind-icon size-3.5 shrink-0"
/>
<span className="truncate text-[13px] font-medium leading-[130%]">
{mention.label}
</span>
{removeAction}
</span>
{mention.summary ? (
<span className="truncate text-[12px] font-normal leading-[130%] text-[var(--text-tertiary)]">
{mention.summary}
</span>
) : null}
</span>
</Wrapper>
);
}
return (
<Wrapper
className={`agent-rich-text-mention-node inline-flex max-w-[min(100%,var(--agent-mention-max-width,16rem))] align-middle ${
selected ? "is-selected" : ""
}`}
contentEditable={false}
>
<MentionPill
aria-label={mention.ariaLabel}
className="top-0 h-6 py-0 align-middle leading-6"
data-agent-mention-href={mention.href}
data-agent-mention-kind={mention.kind}
kind={mention.kind === "session" ? "session" : "issue"}
iconUrl={mention.iconUrl}
label={mention.label}
removable={isEditable}
removeButtonProps={
isEditable
? {
"aria-label": removeActionAriaLabel,
onMouseDown: onRemove
}
: undefined
}
summary={mention.summary}
withTooltipProvider={withTooltipProvider}
/>
</Wrapper>
);
}
export function AgentMentionReadonlyView({
agentTargets,
attrs
}: {
agentTargets?: readonly AgentMessageMarkdownAgentTarget[];
attrs: Record<string, unknown>;
}): JSX.Element {
return (
<AgentMentionPresentationView
agentTargets={agentTargets}
attrs={attrs}
isEditable={false}
selected={false}
Wrapper={AgentMentionStaticWrapper}
/>
);
}
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<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
if (!editor.isEditable) {
return;
}
deleteNode();
if (!hasPromptContentAfterMentionRemoval(editor.state.doc)) {
editor.commands.clearContent();
}
};
return (
<AgentMentionView
attrs={node.attrs ?? {}}
isEditable={isEditable}
onRemove={handleRemove}
removeActionAriaLabel={removeActionAriaLabel}
selected={selected}
Wrapper={AgentMentionNodeWrapper}
/>
);
}