1428 lines
45 KiB
TypeScript
1428 lines
45 KiB
TypeScript
import type { Message } from '@langchain/langgraph-sdk';
|
|
import {
|
|
BookMarkedIcon,
|
|
ChevronDownIcon,
|
|
ClockIcon,
|
|
ExternalLinkIcon,
|
|
FileIcon,
|
|
Loader2Icon,
|
|
PencilIcon,
|
|
} from 'lucide-react';
|
|
import {
|
|
memo,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useMemo,
|
|
useState,
|
|
type AnchorHTMLAttributes,
|
|
type ImgHTMLAttributes,
|
|
type ReactNode,
|
|
} from 'react';
|
|
import rehypeKatex from 'rehype-katex';
|
|
|
|
import { Loader } from '@/components/ai-elements/loader';
|
|
import {
|
|
Message as AIElementMessage,
|
|
MessageContent as AIElementMessageContent,
|
|
MessageResponse as AIElementMessageResponse,
|
|
MessageToolbar,
|
|
} from '@/components/ai-elements/message';
|
|
import {
|
|
Reasoning,
|
|
ReasoningContent,
|
|
ReasoningTrigger,
|
|
} from '@/components/ai-elements/reasoning';
|
|
import { Task, TaskTrigger } from '@/components/ai-elements/task';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import {
|
|
Collapsible,
|
|
CollapsibleContent,
|
|
CollapsibleTrigger,
|
|
} from '@/components/ui/collapsible';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import {
|
|
UploadedFilePreviewDialog,
|
|
useAuthedObjectUrl,
|
|
} from '@/components/workspace/messages/uploaded-file-preview-dialog';
|
|
import { resolveArtifactURL } from '@/core/artifacts/utils';
|
|
import { useChatAskTimes } from '@/core/chat-timestamps';
|
|
import { getBackendBaseURL } from '@/core/config';
|
|
import { readRuntimeEnv } from '@/env';
|
|
import { useI18n } from '@/core/i18n/hooks';
|
|
import {
|
|
extractContentFromMessage,
|
|
extractReasoningContentFromMessage,
|
|
parseUploadedFiles,
|
|
stripArtifactTag,
|
|
stripUploadedFilesTag,
|
|
type FileInMessage,
|
|
} from '@/core/messages/utils';
|
|
import { useRehypeSplitWordsIntoSpans } from '@/core/rehype';
|
|
import {
|
|
applyInlineCitations,
|
|
type RuntimeDisplaySource,
|
|
} from '@/core/skill-display/runtime';
|
|
import { humanMessagePlugins } from '@/core/streamdown';
|
|
import {
|
|
getMultiRoundRagTrace,
|
|
isMultiRoundRagMessage,
|
|
} from '@/core/threads/use-multi-round-rag';
|
|
import { cn } from '@/lib/utils';
|
|
import { WeKnoraSourceDrawer } from '@/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer';
|
|
|
|
import { CitationLink } from '../citations/citation-link';
|
|
import { CopyButton } from '../copy-button';
|
|
import { Tooltip } from '../tooltip';
|
|
import { SkillDisplayBlock } from '../skill-display-block';
|
|
import { ThreadContext } from './context';
|
|
import { DeleteMessageButton } from './delete-message-button';
|
|
import { DownloadWordButton } from './download-word-button';
|
|
import { MarkdownContent, type MarkdownContentProps } from './markdown-content';
|
|
import { MessageTokenUsage } from './message-token-usage';
|
|
import { MultiRoundRagAssistantBody } from './multi-round-rag-message-list';
|
|
|
|
type EmbeddedPageContext = {
|
|
title?: string;
|
|
url?: string;
|
|
content?: string;
|
|
};
|
|
|
|
function extractTagContent(source: string, tagName: string) {
|
|
const match = new RegExp(
|
|
`<${tagName}>\\s*([\\s\\S]*?)\\s*</${tagName}>`,
|
|
'i',
|
|
).exec(source);
|
|
return match?.[1]?.trim() ?? '';
|
|
}
|
|
|
|
function parseEmbeddedPageContext(source: string): {
|
|
content: string;
|
|
pageContext: EmbeddedPageContext | null;
|
|
} {
|
|
const match = /<page_context>\s*([\s\S]*?)\s*<\/page_context>/i.exec(source);
|
|
if (!match) {
|
|
return { content: source, pageContext: null };
|
|
}
|
|
|
|
const body = match[1] ?? '';
|
|
const pageContext = {
|
|
title: extractTagContent(body, 'title') || undefined,
|
|
url: extractTagContent(body, 'url') || undefined,
|
|
content: extractTagContent(body, 'content') || undefined,
|
|
};
|
|
const cleanedContent = source
|
|
.replace(match[0], '')
|
|
.replace(/\n{3,}/g, '\n\n')
|
|
.trim();
|
|
|
|
if (!pageContext.title && !pageContext.url && !pageContext.content) {
|
|
return { content: cleanedContent, pageContext: null };
|
|
}
|
|
|
|
return { content: cleanedContent, pageContext };
|
|
}
|
|
|
|
/**
|
|
* Resolve an in-conversation file path to a fetchable URL.
|
|
*
|
|
* On a public read-only share (`ThreadContext.publicShareCode` set) artifact
|
|
* URLs point at the unauthenticated `/api/public/shares/{code}/files/...`
|
|
* endpoint; otherwise they use the normal auth-gated thread artifact route.
|
|
*/
|
|
function useArtifactResolver(threadId: string): (path: string) => string {
|
|
const publicShareCode = useContext(ThreadContext)?.publicShareCode;
|
|
return useCallback(
|
|
(path: string) => {
|
|
if (publicShareCode) {
|
|
const normalized = path.startsWith('/') ? path : `/${path}`;
|
|
return `${getBackendBaseURL()}/api/public/shares/${encodeURIComponent(
|
|
publicShareCode,
|
|
)}/files${normalized}`;
|
|
}
|
|
return resolveArtifactURL(path, threadId);
|
|
},
|
|
[publicShareCode, threadId],
|
|
);
|
|
}
|
|
|
|
export function MessageListItem({
|
|
className,
|
|
fitRowToContent = false,
|
|
anchorId,
|
|
message,
|
|
questionMarkdown,
|
|
isLoading,
|
|
threadId,
|
|
tokenUsageEnabled = false,
|
|
displaySources = [],
|
|
extraRehypePlugins,
|
|
onEditResubmit,
|
|
onEditSave,
|
|
onMessageDeleted,
|
|
turnTime,
|
|
}: {
|
|
className?: string;
|
|
fitRowToContent?: boolean;
|
|
/**
|
|
* Stable DOM id so the question navigator can scrollIntoView to this
|
|
* message. `scroll-mt` keeps the target clear of the overlay header.
|
|
*/
|
|
anchorId?: string;
|
|
message: Message;
|
|
/** Nearest preceding user question, used by the current-Q&A Word export. */
|
|
questionMarkdown?: string;
|
|
isLoading?: boolean;
|
|
threadId: string;
|
|
tokenUsageEnabled?: boolean;
|
|
displaySources?: RuntimeDisplaySource[];
|
|
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
|
onEditResubmit?: (text: string) => void;
|
|
onEditSave?: (messageId: string, text: string) => void | Promise<void>;
|
|
onMessageDeleted?: (deletedIds: string[]) => void;
|
|
/**
|
|
* Durable ask time of the turn this message belongs to (the preceding
|
|
* question's `client_ts`). Used to time-stamp the AI answer, which carries no
|
|
* timestamp of its own — without it the answer would fall back to the current
|
|
* wall clock at render time and appear to "drift" to now on every reload.
|
|
*/
|
|
turnTime?: string;
|
|
}) {
|
|
const { t } = useI18n();
|
|
const isHuman = message.type === 'human';
|
|
const messageMarkdown =
|
|
extractContentFromMessage(message) ??
|
|
extractReasoningContentFromMessage(message) ??
|
|
'';
|
|
const [assistantEditing, setAssistantEditing] = useState(false);
|
|
const [assistantDraft, setAssistantDraft] = useState(messageMarkdown);
|
|
const [assistantSaving, setAssistantSaving] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!assistantEditing) {
|
|
setAssistantDraft(messageMarkdown);
|
|
}
|
|
}, [assistantEditing, messageMarkdown]);
|
|
|
|
const canSaveAssistantEdit =
|
|
!isLoading &&
|
|
!isHuman &&
|
|
!!onEditSave &&
|
|
!!message.id &&
|
|
!message.id.startsWith('opt-') &&
|
|
!!messageMarkdown;
|
|
const confirmAssistantEdit = useCallback(async () => {
|
|
const value = assistantDraft.trim();
|
|
if (!value || !message.id || !onEditSave || assistantSaving) {
|
|
return;
|
|
}
|
|
setAssistantSaving(true);
|
|
try {
|
|
await onEditSave(message.id, value);
|
|
setAssistantEditing(false);
|
|
} catch {
|
|
// The page-level save callback owns user-facing error toasts. Keep the
|
|
// editor open so the user can adjust and retry.
|
|
} finally {
|
|
setAssistantSaving(false);
|
|
}
|
|
}, [assistantDraft, assistantSaving, message.id, onEditSave]);
|
|
|
|
if (!isHuman && assistantEditing) {
|
|
return (
|
|
<AIElementMessage
|
|
id={anchorId}
|
|
className={cn(
|
|
'group/conversation-message relative',
|
|
fitRowToContent ? 'max-w-full w-fit' : 'w-full',
|
|
anchorId && 'scroll-mt-20',
|
|
className,
|
|
)}
|
|
from="assistant"
|
|
>
|
|
<AIElementMessageContent className="w-full">
|
|
<div className="flex w-full flex-col gap-2 rounded-2xl border border-primary/20 bg-primary/5 p-3">
|
|
<Textarea
|
|
value={assistantDraft}
|
|
autoFocus
|
|
onChange={(event) => setAssistantDraft(event.currentTarget.value)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
|
|
event.preventDefault();
|
|
void confirmAssistantEdit();
|
|
}
|
|
if (event.key === "Escape") {
|
|
event.preventDefault();
|
|
setAssistantEditing(false);
|
|
}
|
|
}}
|
|
className="min-h-32 resize-y text-[15px] leading-7"
|
|
/>
|
|
<div className="flex items-center justify-end gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
disabled={assistantSaving}
|
|
onClick={() => setAssistantEditing(false)}
|
|
>
|
|
{t.common.cancel}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
disabled={!assistantDraft.trim() || assistantSaving}
|
|
onClick={() => void confirmAssistantEdit()}
|
|
>
|
|
{assistantSaving ? "保存中..." : t.common.save}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</AIElementMessageContent>
|
|
</AIElementMessage>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<AIElementMessage
|
|
id={anchorId}
|
|
className={cn(
|
|
'group/conversation-message relative',
|
|
fitRowToContent ? 'max-w-full w-fit' : 'w-full',
|
|
isHuman && fitRowToContent && 'ml-auto',
|
|
anchorId && 'scroll-mt-20',
|
|
className,
|
|
)}
|
|
from={isHuman ? 'user' : 'assistant'}
|
|
>
|
|
<MessageContent
|
|
className={isHuman ? 'w-fit' : 'w-full'}
|
|
message={message}
|
|
isLoading={isLoading}
|
|
threadId={threadId}
|
|
tokenUsageEnabled={tokenUsageEnabled}
|
|
displaySources={displaySources}
|
|
extraRehypePlugins={extraRehypePlugins}
|
|
onEditResubmit={onEditResubmit}
|
|
onEditSave={isLoading ? undefined : onEditSave}
|
|
onMessageDeleted={onMessageDeleted}
|
|
turnTime={turnTime}
|
|
/>
|
|
{/*
|
|
Human messages carry their own inline copy/edit toolbar inside
|
|
MessageContent (above the timestamp). Only assistant answers use the
|
|
floating bottom toolbar here.
|
|
*/}
|
|
{!isLoading && !isHuman && (
|
|
<MessageToolbar
|
|
className={cn(
|
|
'-bottom-8',
|
|
'absolute right-0 left-0 z-20 opacity-0 transition-opacity delay-200 duration-300 group-hover/conversation-message:opacity-100',
|
|
)}
|
|
>
|
|
<div className="flex gap-1">
|
|
<CopyButton clipboardData={messageMarkdown} />
|
|
{messageMarkdown && (
|
|
<DownloadWordButton
|
|
markdown={messageMarkdown}
|
|
fileName="message"
|
|
title="问答回答"
|
|
questionMarkdown={questionMarkdown}
|
|
/>
|
|
)}
|
|
{canSaveAssistantEdit && (
|
|
<Tooltip content="编辑消息">
|
|
<Button
|
|
size="icon-sm"
|
|
type="button"
|
|
variant="ghost"
|
|
className="size-6"
|
|
onClick={() => setAssistantEditing(true)}
|
|
>
|
|
<PencilIcon size={11} />
|
|
</Button>
|
|
</Tooltip>
|
|
)}
|
|
</div>
|
|
</MessageToolbar>
|
|
)}
|
|
</AIElementMessage>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Inline copy/edit affordances + editor for the user's own question bubble.
|
|
*
|
|
* Layout: bubble → hover toolbar (copy + edit) → timestamp. The toolbar sits
|
|
* in normal flow above the timestamp (not floating) so it never covers the
|
|
* following message. Clicking edit turns the bubble into a textarea seeded with
|
|
* the question; confirming sends the edited text as a brand-new turn (the
|
|
* original question stays in the thread).
|
|
*/
|
|
function HumanMessageBlock({
|
|
messageId,
|
|
text,
|
|
pageContext,
|
|
filesList,
|
|
messageTime,
|
|
components,
|
|
onEditResubmit,
|
|
onEditSave,
|
|
deleteButton,
|
|
}: {
|
|
messageId?: string;
|
|
text: string;
|
|
pageContext?: EmbeddedPageContext | null;
|
|
filesList: ReactNode;
|
|
messageTime: { label: string; full: string } | null;
|
|
components: MarkdownContentProps["components"];
|
|
onEditResubmit?: (text: string) => void;
|
|
onEditSave?: (messageId: string, text: string) => void | Promise<void>;
|
|
deleteButton?: ReactNode;
|
|
}) {
|
|
const { t } = useI18n();
|
|
const [editing, setEditing] = useState(false);
|
|
const [draft, setDraft] = useState(text);
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!editing) {
|
|
setDraft(text);
|
|
}
|
|
}, [text, editing]);
|
|
|
|
const confirmEdit = useCallback(async () => {
|
|
const value = draft.trim();
|
|
if (!value || saving) {
|
|
return;
|
|
}
|
|
if (onEditSave && messageId && !messageId.startsWith("opt-")) {
|
|
setSaving(true);
|
|
try {
|
|
await onEditSave(messageId, value);
|
|
setEditing(false);
|
|
} catch {
|
|
// The page-level save callback owns user-facing error toasts. Keep the
|
|
// editor open so the user can adjust and retry.
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
return;
|
|
}
|
|
onEditResubmit?.(value);
|
|
setEditing(false);
|
|
}, [draft, messageId, onEditResubmit, onEditSave, saving]);
|
|
|
|
if (editing) {
|
|
return (
|
|
<div className="ml-auto flex w-full max-w-[min(85%,42rem)] flex-col gap-2">
|
|
{filesList}
|
|
<Textarea
|
|
value={draft}
|
|
autoFocus
|
|
onChange={(event) => setDraft(event.currentTarget.value)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
|
|
event.preventDefault();
|
|
void confirmEdit();
|
|
}
|
|
if (event.key === "Escape") {
|
|
event.preventDefault();
|
|
setEditing(false);
|
|
}
|
|
}}
|
|
className="min-h-20 resize-none text-[15px] leading-7"
|
|
/>
|
|
<div className="flex items-center justify-end gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
disabled={saving}
|
|
onClick={() => setEditing(false)}
|
|
>
|
|
{t.common.cancel}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
onClick={() => void confirmEdit()}
|
|
disabled={!draft.trim() || saving}
|
|
>
|
|
{saving ? "保存中..." : onEditSave ? t.common.save : t.common.confirm}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const messageResponse = text ? (
|
|
<AIElementMessageResponse
|
|
remarkPlugins={humanMessagePlugins.remarkPlugins}
|
|
rehypePlugins={humanMessagePlugins.rehypePlugins}
|
|
components={components}
|
|
parseIncompleteMarkdown={false}
|
|
>
|
|
{text}
|
|
</AIElementMessageResponse>
|
|
) : null;
|
|
|
|
return (
|
|
<div className="ml-auto flex flex-col items-end gap-1">
|
|
{filesList}
|
|
{messageResponse && (
|
|
<AIElementMessageContent className="w-fit">
|
|
{messageResponse}
|
|
</AIElementMessageContent>
|
|
)}
|
|
<MessageTimeStamp time={messageTime} align="right" />
|
|
{pageContext && <EmbeddedPageContextCard pageContext={pageContext} />}
|
|
{(text || deleteButton) && (
|
|
<div className="flex items-center gap-0.5 opacity-0 transition-opacity duration-200 group-hover/conversation-message:opacity-100">
|
|
{text && <CopyButton clipboardData={text} className="size-6" />}
|
|
{text && (onEditResubmit || onEditSave) && (
|
|
<Tooltip content={onEditSave ? "编辑消息" : t.inputBox.editQuestion}>
|
|
<Button
|
|
size="icon-sm"
|
|
type="button"
|
|
variant="ghost"
|
|
className="size-6"
|
|
onClick={() => setEditing(true)}
|
|
>
|
|
<PencilIcon size={11} />
|
|
</Button>
|
|
</Tooltip>
|
|
)}
|
|
{deleteButton}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Custom image component that handles artifact URLs
|
|
*/
|
|
function MessageImage({
|
|
src,
|
|
alt,
|
|
threadId,
|
|
maxWidth = '90%',
|
|
...props
|
|
}: React.ImgHTMLAttributes<HTMLImageElement> & {
|
|
threadId: string;
|
|
maxWidth?: string;
|
|
}) {
|
|
const resolveArtifact = useArtifactResolver(threadId);
|
|
if (!src) return null;
|
|
|
|
const imgClassName = cn('overflow-hidden rounded-lg', `max-w-[${maxWidth}]`);
|
|
|
|
if (typeof src !== 'string') {
|
|
return <img className={imgClassName} src={src} alt={alt} {...props} />;
|
|
}
|
|
|
|
const url = src.startsWith('/mnt/') ? resolveArtifact(src) : src;
|
|
|
|
return (
|
|
<a href={url} target="_blank" rel="noopener noreferrer">
|
|
<img className={imgClassName} src={url} alt={alt} {...props} />
|
|
</a>
|
|
);
|
|
}
|
|
|
|
// While a notebook citation marker is still streaming in (e.g. "[[cite:64" or
|
|
// "[[cite:101]"), Streamdown's parseIncompleteMarkdown treats the trailing
|
|
// unclosed "[" as an incomplete link and rewrites it to
|
|
// "[[cite:64](streamdown:incomplete-link)", which the rehypeCitations regex can
|
|
// no longer match — so the citation flickers as a broken link mid-stream. We
|
|
// strip the half-arrived marker so it stays hidden until the closing "]]"
|
|
// arrives, at which point rehypeCitations renders it. A complete
|
|
// "[[cite:101]]" has no trailing incomplete marker, so this is a no-op.
|
|
const INCOMPLETE_CITE_TAIL = /\[\[(?:c(?:i(?:t(?:e(?::\d*\]?)?)?)?)?)?$/;
|
|
|
|
function stripIncompleteCitationMarker(content: string): string {
|
|
return content.replace(INCOMPLETE_CITE_TAIL, '');
|
|
}
|
|
|
|
function MessageContent_({
|
|
className,
|
|
message,
|
|
isLoading = false,
|
|
threadId,
|
|
tokenUsageEnabled = false,
|
|
displaySources = [],
|
|
extraRehypePlugins,
|
|
onEditResubmit,
|
|
onEditSave,
|
|
onMessageDeleted,
|
|
turnTime,
|
|
}: {
|
|
className?: string;
|
|
message: Message;
|
|
isLoading?: boolean;
|
|
threadId: string;
|
|
tokenUsageEnabled?: boolean;
|
|
displaySources?: RuntimeDisplaySource[];
|
|
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
|
|
onEditResubmit?: (text: string) => void;
|
|
onEditSave?: (messageId: string, text: string) => void | Promise<void>;
|
|
onMessageDeleted?: (deletedIds: string[]) => void;
|
|
turnTime?: string;
|
|
}) {
|
|
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
|
|
|
|
// Streamdown splits content into per-block processors; calling the factory
|
|
// once here ensures all blocks share the same transformer instance (and thus
|
|
// the same citation counter) for this message.
|
|
const stableExtraPlugins = useMemo(() => {
|
|
if (!extraRehypePlugins?.length) return [];
|
|
return extraRehypePlugins.map((factory) => {
|
|
const transformer = factory();
|
|
return () => transformer;
|
|
});
|
|
}, [extraRehypePlugins]);
|
|
|
|
const rehypePlugins = useMemo(
|
|
() =>
|
|
extraRehypePlugins?.length
|
|
? [...baseRehypePlugins, ...extraRehypePlugins]
|
|
: baseRehypePlugins,
|
|
[baseRehypePlugins, extraRehypePlugins],
|
|
);
|
|
const askTimes = useChatAskTimes();
|
|
const isHuman = message.type === 'human';
|
|
const isSingleRoundRag =
|
|
message.additional_kwargs?.single_round_rag === true;
|
|
const isMultiRoundRag = isMultiRoundRagMessage(message);
|
|
const multiRoundTrace = isMultiRoundRag
|
|
? getMultiRoundRagTrace(message)
|
|
: null;
|
|
const [selectedCitation, setSelectedCitation] =
|
|
useState<RuntimeDisplaySource | null>(null);
|
|
const resolveArtifact = useArtifactResolver(threadId);
|
|
const handleCitationClick = useCallback(
|
|
(referenceId: string) => {
|
|
const referenceIndex = Number(referenceId);
|
|
if (!Number.isInteger(referenceIndex) || referenceIndex < 1) return;
|
|
const source =
|
|
displaySources.find((item) => item.index === referenceIndex) ??
|
|
displaySources[referenceIndex - 1];
|
|
if (source) setSelectedCitation(source);
|
|
},
|
|
[displaySources],
|
|
);
|
|
const components = useMemo(
|
|
() => ({
|
|
img: (props: ImgHTMLAttributes<HTMLImageElement>) => (
|
|
<MessageImage {...props} threadId={threadId} maxWidth="90%" />
|
|
),
|
|
a: ({ href, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
|
if (href?.startsWith('#reference-')) {
|
|
return (
|
|
<CitationLink
|
|
href={href}
|
|
{...props}
|
|
onReferenceClick={
|
|
isSingleRoundRag ? handleCitationClick : undefined
|
|
}
|
|
/>
|
|
);
|
|
}
|
|
if (
|
|
typeof props.children === 'string' &&
|
|
/^citation:/i.test(props.children)
|
|
) {
|
|
return <CitationLink href={href} {...props} />;
|
|
}
|
|
if (href?.startsWith('/mnt/')) {
|
|
const url = resolveArtifact(href);
|
|
return (
|
|
<a
|
|
{...props}
|
|
href={url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
/>
|
|
);
|
|
}
|
|
return <a {...props} href={href} />;
|
|
},
|
|
}),
|
|
[
|
|
handleCitationClick,
|
|
isSingleRoundRag,
|
|
resolveArtifact,
|
|
threadId,
|
|
],
|
|
);
|
|
|
|
// Question carries its own `client_ts` (round-trips through the checkpoint).
|
|
// The AI answer has no timestamp of its own, so it inherits the turn's ask
|
|
// time (its question's `client_ts`, threaded down as `turnTime`). This is
|
|
// durable and turn-accurate, and — unlike the old per-id `now` fallback —
|
|
// never drifts to the current clock when an unfinished answer is reloaded.
|
|
const ownAskTime = message.id ? askTimes[message.id] : undefined;
|
|
const messageTime = formatAskedTime(
|
|
message.additional_kwargs?.client_ts ??
|
|
(isHuman ? ownAskTime : (turnTime ?? ownAskTime)),
|
|
);
|
|
|
|
const rawContent = extractContentFromMessage(message);
|
|
const reasoningContent = extractReasoningContentFromMessage(message);
|
|
|
|
const files = useMemo(() => {
|
|
const files = message.additional_kwargs?.files;
|
|
if (!Array.isArray(files) || files.length === 0) {
|
|
if (rawContent.includes('<uploaded_files>')) {
|
|
// If the content contains the <uploaded_files> tag, we return the parsed files from the content for backward compatibility.
|
|
return parseUploadedFiles(rawContent);
|
|
}
|
|
return null;
|
|
}
|
|
return files as FileInMessage[];
|
|
}, [message.additional_kwargs?.files, rawContent]);
|
|
|
|
const contentToDisplay = useMemo(() => {
|
|
if (isHuman) {
|
|
return rawContent
|
|
? stripArtifactTag(stripUploadedFilesTag(rawContent))
|
|
: '';
|
|
}
|
|
if (isMultiRoundRag) {
|
|
return stripIncompleteCitationMarker(rawContent ?? '');
|
|
}
|
|
const withCitations = applyInlineCitations(rawContent ?? '', displaySources);
|
|
return stripIncompleteCitationMarker(withCitations);
|
|
}, [rawContent, isHuman, isMultiRoundRag, displaySources]);
|
|
const markdownRehypePlugins = useMemo(
|
|
() => [...rehypePlugins, [rehypeKatex, { output: 'html' as const }]],
|
|
[rehypePlugins],
|
|
);
|
|
const parsedHumanContent = useMemo(
|
|
() =>
|
|
isHuman
|
|
? parseEmbeddedPageContext(contentToDisplay)
|
|
: { content: contentToDisplay, pageContext: null },
|
|
[contentToDisplay, isHuman],
|
|
);
|
|
|
|
const filesList =
|
|
files && files.length > 0 ? (
|
|
<RichFilesList files={files} threadId={threadId} />
|
|
) : null;
|
|
|
|
// Uploading state: mock AI message shown while files upload
|
|
if (message.additional_kwargs?.element === 'task') {
|
|
return (
|
|
<AIElementMessageContent className={className}>
|
|
<Task defaultOpen={false}>
|
|
<TaskTrigger title="">
|
|
<div className="text-muted-foreground flex w-full cursor-default items-center gap-2 text-sm select-none">
|
|
<Loader className="size-4" />
|
|
<span>{contentToDisplay}</span>
|
|
</div>
|
|
</TaskTrigger>
|
|
</Task>
|
|
</AIElementMessageContent>
|
|
);
|
|
}
|
|
|
|
// Reasoning-only AI message (no main response content yet)
|
|
if (!isHuman && reasoningContent && !rawContent && !isMultiRoundRag) {
|
|
return (
|
|
<AIElementMessageContent className={className}>
|
|
<Reasoning isStreaming={isLoading}>
|
|
<ReasoningTrigger />
|
|
<ReasoningContent>{reasoningContent}</ReasoningContent>
|
|
</Reasoning>
|
|
<MessageTokenUsage
|
|
enabled={tokenUsageEnabled}
|
|
isLoading={isLoading}
|
|
message={message}
|
|
/>
|
|
</AIElementMessageContent>
|
|
);
|
|
}
|
|
|
|
if (isHuman) {
|
|
// Only the user's own question carries a delete affordance, and only once
|
|
// it exists server-side (real, non-optimistic id). Deleting it cascades to
|
|
// its answer on the backend.
|
|
const canDelete =
|
|
!!onMessageDeleted &&
|
|
!!message.id &&
|
|
!message.id.startsWith('opt-') &&
|
|
!isMultiRoundRag &&
|
|
!isSingleRoundRag;
|
|
return (
|
|
<HumanMessageBlock
|
|
messageId={message.id}
|
|
text={parsedHumanContent.content}
|
|
pageContext={parsedHumanContent.pageContext}
|
|
filesList={filesList}
|
|
messageTime={messageTime}
|
|
components={components}
|
|
onEditResubmit={
|
|
isMultiRoundRag || isSingleRoundRag ? undefined : onEditResubmit
|
|
}
|
|
onEditSave={isMultiRoundRag || isSingleRoundRag ? undefined : onEditSave}
|
|
deleteButton={
|
|
canDelete ? (
|
|
<DeleteMessageButton
|
|
threadId={threadId}
|
|
messageId={message.id!}
|
|
onDeleted={(ids) => onMessageDeleted?.(ids)}
|
|
className="size-6"
|
|
/>
|
|
) : null
|
|
}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<AIElementMessageContent className={className}>
|
|
{filesList}
|
|
<div data-reference-batch-id={message.id} className="contents">
|
|
{isMultiRoundRag ? (
|
|
<MultiRoundRagAssistantBody
|
|
content={contentToDisplay}
|
|
thinkingTrace={multiRoundTrace}
|
|
isLoading={isLoading}
|
|
rehypePlugins={markdownRehypePlugins}
|
|
/>
|
|
) : (
|
|
<AssistantDisplayContent
|
|
content={contentToDisplay}
|
|
displaySources={displaySources}
|
|
isLoading={isLoading}
|
|
renderSkillDisplayBlocks={false}
|
|
rehypePlugins={markdownRehypePlugins}
|
|
components={components}
|
|
/>
|
|
)}
|
|
</div>
|
|
{isSingleRoundRag && displaySources.length > 0 && (
|
|
<FrontendRagSourcesPanel
|
|
sources={displaySources}
|
|
onSelect={setSelectedCitation}
|
|
/>
|
|
)}
|
|
<MessageTokenUsage
|
|
enabled={tokenUsageEnabled}
|
|
isLoading={isLoading}
|
|
message={message}
|
|
/>
|
|
{!isLoading && contentToDisplay ? (
|
|
<MessageTimeStamp time={messageTime} align="left" />
|
|
) : null}
|
|
</AIElementMessageContent>
|
|
{isSingleRoundRag && (
|
|
<SingleRoundRagCitationDialog
|
|
source={selectedCitation}
|
|
onOpenChange={(open) => {
|
|
if (!open) setSelectedCitation(null);
|
|
}}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 单轮 RAG 专属的「引用来源」折叠面板。
|
|
*
|
|
* 多轮 RAG 的检索卡片已经画在思考过程里,正文下面不再重复一份来源列表。
|
|
*/
|
|
function FrontendRagSourcesPanel({
|
|
sources,
|
|
onSelect,
|
|
}: {
|
|
sources: RuntimeDisplaySource[];
|
|
onSelect: (source: RuntimeDisplaySource) => void;
|
|
}) {
|
|
const [open, setOpen] = useState(false);
|
|
if (sources.length === 0) return null;
|
|
return (
|
|
<Collapsible open={open} onOpenChange={setOpen} className="mt-3 w-full max-w-5xl">
|
|
<CollapsibleTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1.5 text-xs transition-colors"
|
|
>
|
|
<BookMarkedIcon className="size-3.5" />
|
|
<span className="font-medium">引用来源</span>
|
|
<span className="text-muted-foreground/70">({sources.length})</span>
|
|
<ChevronDownIcon
|
|
className={cn(
|
|
'size-3.5 transition-transform duration-200',
|
|
!open && '-rotate-90',
|
|
)}
|
|
/>
|
|
</button>
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent className="mt-2 grid gap-2">
|
|
{sources.map((source, idx) => (
|
|
<button
|
|
key={source.id ?? `${source.title}-${idx}`}
|
|
type="button"
|
|
onClick={() => onSelect(source)}
|
|
className="bg-card hover:bg-accent block w-full cursor-pointer rounded-lg border p-3 text-left transition-colors"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<span className="bg-primary/10 text-primary flex size-5 shrink-0 items-center justify-center rounded-full text-xs font-semibold">
|
|
{source.index}
|
|
</span>
|
|
<span
|
|
className="text-foreground line-clamp-1 min-w-0 flex-1 text-sm font-medium"
|
|
title={source.title}
|
|
>
|
|
{source.title}
|
|
</span>
|
|
{source.type && (
|
|
<span className="bg-muted text-muted-foreground shrink-0 rounded px-1.5 py-0.5 text-xs">
|
|
{source.type}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{(source.content || source.snippet) && (
|
|
<p className="text-muted-foreground mt-1.5 line-clamp-2 text-xs leading-relaxed">
|
|
{source.content || source.snippet}
|
|
</p>
|
|
)}
|
|
</button>
|
|
))}
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
);
|
|
}
|
|
|
|
export function SingleRoundRagCitationDialog({
|
|
source,
|
|
onOpenChange,
|
|
}: {
|
|
source: RuntimeDisplaySource | null;
|
|
onOpenChange: (open: boolean) => void;
|
|
}) {
|
|
const isLlmWikiSource =
|
|
source?.skillName?.startsWith('llmwiki_') ||
|
|
source?.type?.startsWith('llmwiki_') ||
|
|
source?.raw?.type === 'llmwiki_wiki_index_intro';
|
|
if (isLlmWikiSource) {
|
|
const content = source?.content || source?.snippet || '';
|
|
return (
|
|
<WeKnoraSourceDrawer
|
|
open={!!source}
|
|
target={null}
|
|
title={source?.title || 'Wiki 文档'}
|
|
quote={content}
|
|
inlineContent={content}
|
|
inlineKnowledgeBaseId={String(source?.raw?.knowledge_base_id || '')}
|
|
onOpenChange={onOpenChange}
|
|
/>
|
|
);
|
|
}
|
|
const sourceUrl = resolveSingleRoundRagSourceUrl(source);
|
|
return (
|
|
<Dialog open={!!source} onOpenChange={onOpenChange}>
|
|
<DialogContent className="max-h-[min(42rem,calc(100vh-2rem))] max-w-2xl overflow-hidden p-5 sm:max-w-2xl">
|
|
<DialogHeader className="pr-8">
|
|
<DialogTitle className="leading-relaxed">
|
|
{source?.title || "引用资料"}
|
|
</DialogTitle>
|
|
<DialogDescription className="space-y-1 text-xs">
|
|
<span className="block">来源:{source?.source || source?.type || "未知来源"}</span>
|
|
{source?.time && <span className="block">时间:{source.time}</span>}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
{sourceUrl && (
|
|
<a
|
|
href={sourceUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="border-input hover:bg-accent inline-flex w-fit items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors"
|
|
>
|
|
<ExternalLinkIcon className="size-4" />
|
|
查看原文链接
|
|
</a>
|
|
)}
|
|
<div className="bg-muted/50 max-h-[min(26rem,calc(100vh-16rem))] overflow-auto rounded-lg p-3 text-sm leading-7 whitespace-pre-wrap">
|
|
{source?.content || source?.snippet || "暂无引用内容"}
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
function resolveSingleRoundRagSourceUrl(
|
|
source: RuntimeDisplaySource | null,
|
|
): string | undefined {
|
|
if (!source) return undefined;
|
|
if (source.url && /^https?:\/\//i.test(source.url)) return source.url;
|
|
|
|
const raw = source.raw ?? {};
|
|
const recordId =
|
|
source.id ??
|
|
readString(raw.recUuid) ??
|
|
readString(raw.rec_uuid) ??
|
|
readString(raw.uuid);
|
|
const knowledgeUrl = readRuntimeEnv("VITE_AIPI_KNOWLEDGE_URL")?.replace(/\/$/, "");
|
|
if (!knowledgeUrl || !recordId) return undefined;
|
|
return `${knowledgeUrl}/#/knowledge/mid-detail?recUuid=${encodeURIComponent(recordId)}`;
|
|
}
|
|
|
|
function readString(value: unknown): string | undefined {
|
|
if (typeof value === "string" && value.trim()) return value;
|
|
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
|
return undefined;
|
|
}
|
|
|
|
function EmbeddedPageContextCard({
|
|
pageContext,
|
|
}: {
|
|
pageContext: EmbeddedPageContext;
|
|
}) {
|
|
const previewText = [pageContext.title, pageContext.url, pageContext.content]
|
|
.filter(Boolean)
|
|
.join(' | ');
|
|
|
|
return (
|
|
<div className="bg-muted/70 ml-auto flex w-fit min-w-0 max-w-[min(24rem,calc(100vw-2rem),100%)] flex-col gap-2 rounded-2xl border px-3 py-2 text-sm shadow-sm">
|
|
<div
|
|
className="text-muted-foreground line-clamp-2 min-w-0 [overflow-wrap:anywhere] break-words text-xs"
|
|
title={previewText}
|
|
>
|
|
{previewText}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Format a wall-clock message time as ``YYYY-MM-DD HH:mm`` for display, plus a
|
|
* full localized string for the hover title. Returns ``null`` when no/invalid
|
|
* timestamp is present.
|
|
*/
|
|
function formatAskedTime(
|
|
clientTs: unknown,
|
|
): { label: string; full: string } | null {
|
|
if (typeof clientTs !== 'string' || !clientTs) return null;
|
|
const date = new Date(clientTs);
|
|
if (Number.isNaN(date.getTime())) return null;
|
|
const pad = (n: number) => String(n).padStart(2, '0');
|
|
const label =
|
|
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
|
|
` ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
return { label, full: date.toLocaleString() };
|
|
}
|
|
|
|
/**
|
|
* Small date+time label shown under a message. ``align`` controls the corner:
|
|
* ``right`` for the user's own question, ``left`` for the model's answer.
|
|
*/
|
|
function MessageTimeStamp({
|
|
time,
|
|
align = 'right',
|
|
}: {
|
|
time: { label: string; full: string } | null;
|
|
align?: 'left' | 'right';
|
|
}) {
|
|
if (!time) return null;
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'text-muted-foreground/60 flex items-center gap-1 text-[11px] leading-none tabular-nums',
|
|
align === 'right' ? 'justify-end pr-0.5' : 'justify-start pl-0.5',
|
|
)}
|
|
title={time.full}
|
|
>
|
|
<ClockIcon className="size-3 shrink-0" />
|
|
<span>{time.label}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get file extension and check helpers
|
|
*/
|
|
const getFileExt = (filename: string) =>
|
|
filename.split('.').pop()?.toLowerCase() ?? '';
|
|
|
|
const FILE_TYPE_MAP: Record<string, string> = {
|
|
json: 'JSON',
|
|
csv: 'CSV',
|
|
txt: 'TXT',
|
|
md: 'Markdown',
|
|
py: 'Python',
|
|
js: 'JavaScript',
|
|
ts: 'TypeScript',
|
|
tsx: 'TSX',
|
|
jsx: 'JSX',
|
|
html: 'HTML',
|
|
css: 'CSS',
|
|
xml: 'XML',
|
|
yaml: 'YAML',
|
|
yml: 'YAML',
|
|
pdf: 'PDF',
|
|
png: 'PNG',
|
|
jpg: 'JPG',
|
|
jpeg: 'JPEG',
|
|
gif: 'GIF',
|
|
svg: 'SVG',
|
|
zip: 'ZIP',
|
|
tar: 'TAR',
|
|
gz: 'GZ',
|
|
};
|
|
|
|
const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp'];
|
|
|
|
function getFileTypeLabel(filename: string): string {
|
|
const ext = getFileExt(filename);
|
|
return FILE_TYPE_MAP[ext] ?? (ext.toUpperCase() || 'FILE');
|
|
}
|
|
|
|
function isImageFile(filename: string): boolean {
|
|
return IMAGE_EXTENSIONS.includes(getFileExt(filename));
|
|
}
|
|
|
|
/**
|
|
* Format bytes to human-readable size string
|
|
*/
|
|
function formatBytes(bytes: number): string {
|
|
if (bytes === 0) return '0 B';
|
|
const kb = bytes / 1024;
|
|
if (kb < 1024) return `${kb.toFixed(1)} KB`;
|
|
return `${(kb / 1024).toFixed(1)} MB`;
|
|
}
|
|
|
|
/**
|
|
* List of files from additional_kwargs.files (with optional upload status)
|
|
*/
|
|
function RichFilesList({
|
|
files,
|
|
threadId,
|
|
}: {
|
|
files: FileInMessage[];
|
|
threadId: string;
|
|
}) {
|
|
if (files.length === 0) return null;
|
|
return (
|
|
<div className="mb-2 flex flex-wrap justify-end gap-2">
|
|
{files.map((file, index) => (
|
|
<RichFileCard
|
|
key={`${file.filename}-${index}`}
|
|
file={file}
|
|
threadId={threadId}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Single file card that handles FileInMessage (supports uploading state)
|
|
*/
|
|
function RichFileCard({
|
|
file,
|
|
threadId,
|
|
}: {
|
|
file: FileInMessage;
|
|
threadId: string;
|
|
}) {
|
|
const { t } = useI18n();
|
|
const resolveArtifact = useArtifactResolver(threadId);
|
|
const [previewOpen, setPreviewOpen] = useState(false);
|
|
const isUploading = file.status === 'uploading';
|
|
const isImage = isImageFile(file.filename);
|
|
const fileUrl = file.path ? resolveArtifact(file.path) : null;
|
|
// Thumbnails must be fetched with auth: the artifact route is gated and a
|
|
// plain <img src> sends no Authorization header → 401 (broken image). Fetch
|
|
// the bytes via apiFetch and display the resulting object URL instead.
|
|
const { objectUrl: thumbUrl, loading: thumbLoading } = useAuthedObjectUrl(
|
|
fileUrl,
|
|
isImage && !isUploading && !!fileUrl,
|
|
);
|
|
|
|
if (isUploading) {
|
|
return (
|
|
<div className="bg-background border-border/40 flex max-w-50 min-w-30 flex-col gap-1 rounded-lg border p-3 opacity-60 shadow-sm">
|
|
<div className="flex items-start gap-2">
|
|
<Loader2Icon className="text-muted-foreground mt-0.5 size-4 shrink-0 animate-spin" />
|
|
<span
|
|
className="text-foreground truncate text-sm font-medium"
|
|
title={file.filename}
|
|
>
|
|
{file.filename}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center justify-between gap-2">
|
|
<Badge
|
|
variant="secondary"
|
|
className="rounded px-1.5 py-0.5 text-[10px] font-normal"
|
|
>
|
|
{getFileTypeLabel(file.filename)}
|
|
</Badge>
|
|
<span className="text-muted-foreground text-[10px]">
|
|
{t.uploads.uploading}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!fileUrl) return null;
|
|
|
|
const preview = (
|
|
<UploadedFilePreviewDialog
|
|
filename={file.filename}
|
|
fileUrl={fileUrl}
|
|
open={previewOpen}
|
|
onOpenChange={setPreviewOpen}
|
|
/>
|
|
);
|
|
|
|
if (isImage) {
|
|
return (
|
|
<>
|
|
<button
|
|
type="button"
|
|
onClick={() => setPreviewOpen(true)}
|
|
title={file.filename}
|
|
className="group border-border/40 relative block cursor-pointer overflow-hidden rounded-lg border"
|
|
>
|
|
{thumbUrl ? (
|
|
<img
|
|
src={thumbUrl}
|
|
alt={file.filename}
|
|
className="h-32 w-auto max-w-60 object-cover transition-transform group-hover:scale-105"
|
|
/>
|
|
) : (
|
|
<div className="bg-muted/40 text-muted-foreground flex h-32 w-44 items-center justify-center gap-2 text-xs">
|
|
{thumbLoading ? (
|
|
<Loader2Icon className="size-4 animate-spin" />
|
|
) : (
|
|
<>
|
|
<FileIcon className="size-4" />
|
|
<span className="truncate">{file.filename}</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</button>
|
|
{preview}
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<button
|
|
type="button"
|
|
onClick={() => setPreviewOpen(true)}
|
|
title={file.filename}
|
|
className="bg-background border-border/40 hover:bg-accent/50 flex max-w-50 min-w-30 cursor-pointer flex-col gap-1 rounded-lg border p-3 text-left shadow-sm transition-colors"
|
|
>
|
|
<div className="flex items-start gap-2">
|
|
<FileIcon className="text-muted-foreground mt-0.5 size-4 shrink-0" />
|
|
<span
|
|
className="text-foreground truncate text-sm font-medium"
|
|
title={file.filename}
|
|
>
|
|
{file.filename}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center justify-between gap-2">
|
|
<Badge
|
|
variant="secondary"
|
|
className="rounded px-1.5 py-0.5 text-[10px] font-normal"
|
|
>
|
|
{getFileTypeLabel(file.filename)}
|
|
</Badge>
|
|
<span className="text-muted-foreground text-[10px]">
|
|
{formatBytes(file.size)}
|
|
</span>
|
|
</div>
|
|
</button>
|
|
{preview}
|
|
</>
|
|
);
|
|
}
|
|
|
|
const MessageContent = memo(MessageContent_);
|
|
|
|
function AssistantDisplayContent({
|
|
content,
|
|
displaySources,
|
|
isLoading,
|
|
renderSkillDisplayBlocks,
|
|
rehypePlugins,
|
|
components,
|
|
}: {
|
|
content: string;
|
|
displaySources: RuntimeDisplaySource[];
|
|
isLoading: boolean;
|
|
renderSkillDisplayBlocks: boolean;
|
|
rehypePlugins: MarkdownContentProps['rehypePlugins'];
|
|
components: MarkdownContentProps['components'];
|
|
}) {
|
|
const { parts, placedKeys } = useMemo(
|
|
() =>
|
|
splitSkillDisplayPlaceholders(
|
|
content,
|
|
displaySources,
|
|
renderSkillDisplayBlocks,
|
|
),
|
|
[content, displaySources, renderSkillDisplayBlocks],
|
|
);
|
|
const fallbackBlocks = useMemo(() => {
|
|
if (!renderSkillDisplayBlocks) {
|
|
return [];
|
|
}
|
|
return groupDisplaySources(displaySources).filter(
|
|
(group) => group.mode !== 'citation' && !placedKeys.has(group.key),
|
|
);
|
|
}, [displaySources, placedKeys, renderSkillDisplayBlocks]);
|
|
|
|
return (
|
|
<>
|
|
{parts.map((part, index) =>
|
|
part.type === 'markdown' ? (
|
|
<MarkdownContent
|
|
key={`markdown-${index}`}
|
|
content={part.content}
|
|
isLoading={isLoading}
|
|
rehypePlugins={rehypePlugins}
|
|
className="my-3"
|
|
components={components}
|
|
/>
|
|
) : (
|
|
<SkillDisplayBlock
|
|
key={`display-${index}`}
|
|
mode={part.mode}
|
|
sources={part.sources}
|
|
/>
|
|
),
|
|
)}
|
|
{fallbackBlocks.map((group) => (
|
|
<SkillDisplayBlock
|
|
key={`fallback-${group.key}`}
|
|
mode={group.mode}
|
|
sources={group.sources}
|
|
/>
|
|
))}
|
|
</>
|
|
);
|
|
}
|
|
|
|
type DisplayPart =
|
|
| { type: 'markdown'; content: string }
|
|
| {
|
|
type: 'display';
|
|
key: string;
|
|
mode: RuntimeDisplaySource['mode'];
|
|
sources: RuntimeDisplaySource[];
|
|
};
|
|
|
|
function splitSkillDisplayPlaceholders(
|
|
content: string,
|
|
displaySources: RuntimeDisplaySource[],
|
|
renderSkillDisplayBlocks: boolean,
|
|
): { parts: DisplayPart[]; placedKeys: Set<string> } {
|
|
const pattern = /<skill-display\s+([^>]*?)\/>/g;
|
|
const parts: DisplayPart[] = [];
|
|
const placedKeys = new Set<string>();
|
|
let cursor = 0;
|
|
for (const match of content.matchAll(pattern)) {
|
|
const start = match.index ?? 0;
|
|
const before = content.slice(cursor, start);
|
|
if (before.trim()) {
|
|
parts.push({ type: 'markdown', content: before });
|
|
}
|
|
const attrs = parseDisplayAttrs(match[1] ?? '');
|
|
const group = findDisplayGroup(displaySources, attrs.tool, attrs.mode);
|
|
if (group) {
|
|
placedKeys.add(group.key);
|
|
}
|
|
if (group && renderSkillDisplayBlocks) {
|
|
parts.push({
|
|
type: 'display',
|
|
key: group.key,
|
|
mode: group.mode,
|
|
sources: group.sources,
|
|
});
|
|
}
|
|
cursor = start + match[0].length;
|
|
}
|
|
const rest = content.slice(cursor);
|
|
if (rest.trim() || parts.length === 0) {
|
|
parts.push({ type: 'markdown', content: rest });
|
|
}
|
|
return { parts, placedKeys };
|
|
}
|
|
|
|
function parseDisplayAttrs(raw: string): {
|
|
tool?: string;
|
|
mode?: RuntimeDisplaySource['mode'];
|
|
} {
|
|
const attrs: Record<string, string> = {};
|
|
for (const match of raw.matchAll(/([a-zA-Z][\w-]*)="([^"]*)"/g)) {
|
|
attrs[match[1]!] = match[2]!;
|
|
}
|
|
return {
|
|
tool: attrs.tool || attrs.skill || attrs.name,
|
|
mode: attrs.mode as RuntimeDisplaySource['mode'] | undefined,
|
|
};
|
|
}
|
|
|
|
function findDisplayGroup(
|
|
sources: RuntimeDisplaySource[],
|
|
tool?: string,
|
|
mode?: RuntimeDisplaySource['mode'],
|
|
) {
|
|
return groupDisplaySources(sources).find((group) => {
|
|
if (mode && group.mode !== mode) {
|
|
return false;
|
|
}
|
|
if (!tool) {
|
|
return true;
|
|
}
|
|
const left = normalizeName(group.skillName);
|
|
const right = normalizeName(tool);
|
|
return left === right || left.includes(right) || right.includes(left);
|
|
});
|
|
}
|
|
|
|
function groupDisplaySources(sources: RuntimeDisplaySource[]) {
|
|
const groups = new Map<
|
|
string,
|
|
{
|
|
key: string;
|
|
skillName: string;
|
|
mode: RuntimeDisplaySource['mode'];
|
|
sources: RuntimeDisplaySource[];
|
|
}
|
|
>();
|
|
for (const source of sources) {
|
|
const key = `${source.skillName}:${source.mode}`;
|
|
const group = groups.get(key) ?? {
|
|
key,
|
|
skillName: source.skillName,
|
|
mode: source.mode,
|
|
sources: [],
|
|
};
|
|
group.sources.push(source);
|
|
groups.set(key, group);
|
|
}
|
|
return [...groups.values()];
|
|
}
|
|
|
|
function normalizeName(name: string) {
|
|
return name.toLowerCase().replace(/[^a-z0-9]+/g, '');
|
|
}
|