Files
deerflow-code/frontend-web/src/components/workspace/messages/message-list-item.tsx
T

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, '');
}