mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
fix(frontend): resolve relative message links to artifacts (#7356)
* fix(frontend): resolve relative links in messages to thread artifacts An assistant message that links a file it wrote with a relative destination, e.g. [Report](report.md), rendered a plain relative href. The browser resolved it against the chat route to /workspace/chats/report.md, so clicking it opened a "thread" whose id is the file name and the Gateway rejected it (422). Only /mnt/ links were rewritten, and the clarification and present_files message text did not get the thread-aware link renderer at all. - A relative link (no scheme, no leading /, no .. segment) that suffix-matches exactly one thread artifact now opens that artifact in a new tab, using the rule message images already use. When zero or several artifacts match it renders as text instead of a link to a broken chat URL. Links outside the message list are unchanged. - The artifact list reaches the link renderer through a context: Streamdown memoizes its root without comparing `components`, so a renderer built before the list arrives would keep the stale list. - /mnt/ artifact links get the normal link styling (they rendered as unstyled text before). - A reopened thread rebuilds its values from the POST /history head, which carries messages but not `artifacts`. The message list and the artifact panel now add the /mnt/ paths from present_files calls in the messages, so links resolve and the panel lists the files after reload. Co-Authored-By: Claude Code <noreply@anthropic.com> * fix(frontend): address artifact link review --------- Co-authored-by: huangzz <110116@nd.com> Co-authored-by: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
ef54c14b4d
commit
0530d0c7c6
@ -22,6 +22,10 @@ import {
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import {
|
||||
areStringArraysEqual,
|
||||
collectThreadArtifactPaths,
|
||||
} from "@/core/artifacts/utils";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { env } from "@/env";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
@ -137,10 +141,20 @@ const ChatBoxContent: React.FC<ChatBoxProps> = ({
|
||||
const browserViewOpen = browserEnabled && (browserView?.open ?? false);
|
||||
|
||||
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
|
||||
// A reopened thread's values carry no `artifacts` (see
|
||||
// collectThreadArtifactPaths); recover them from its present_files calls.
|
||||
const stateArtifacts = thread.values.artifacts;
|
||||
const threadArtifactPaths = useMemo(
|
||||
() =>
|
||||
collectThreadArtifactPaths(
|
||||
Array.isArray(stateArtifacts) ? stateArtifacts : [],
|
||||
thread.messages,
|
||||
),
|
||||
[stateArtifacts, thread.messages],
|
||||
);
|
||||
useEffect(() => {
|
||||
const threadArtifacts = Array.isArray(thread.values.artifacts)
|
||||
? thread.values.artifacts
|
||||
: undefined;
|
||||
const threadArtifacts =
|
||||
threadArtifactPaths.length > 0 ? [...threadArtifactPaths] : undefined;
|
||||
|
||||
if (threadIdRef.current !== threadId) {
|
||||
threadIdRef.current = threadId;
|
||||
@ -150,7 +164,7 @@ const ChatBoxContent: React.FC<ChatBoxProps> = ({
|
||||
// Update artifacts from the current thread
|
||||
// An empty initial state must not erase artifacts restored by the provider
|
||||
// before the persisted thread state has arrived.
|
||||
if (threadArtifacts && threadArtifacts.length > 0) {
|
||||
if (threadArtifacts && !areStringArraysEqual(artifacts, threadArtifacts)) {
|
||||
setArtifacts(threadArtifacts);
|
||||
}
|
||||
|
||||
@ -173,12 +187,13 @@ const ChatBoxContent: React.FC<ChatBoxProps> = ({
|
||||
}
|
||||
}, [
|
||||
threadId,
|
||||
artifacts,
|
||||
autoSelectFirstArtifact,
|
||||
deselect,
|
||||
selectArtifact,
|
||||
selectedArtifact,
|
||||
setArtifacts,
|
||||
thread.values.artifacts,
|
||||
threadArtifactPaths,
|
||||
]);
|
||||
|
||||
const artifactPanelOpen = useMemo(() => {
|
||||
|
||||
@ -6,7 +6,7 @@ import {
|
||||
Loader2Icon,
|
||||
MessageCircleQuestionMarkIcon,
|
||||
} from "lucide-react";
|
||||
import { useId, useState, type KeyboardEvent } from "react";
|
||||
import { useId, useMemo, useState, type KeyboardEvent } from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@ -37,6 +37,7 @@ import { isIMEComposing } from "@/lib/ime";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { MarkdownContent } from "./markdown-content";
|
||||
import { createMarkdownLinkComponent } from "./markdown-link";
|
||||
|
||||
export type HumanInputSubmitResult = boolean | void;
|
||||
|
||||
@ -219,17 +220,23 @@ export function HumanInputCard({
|
||||
disabled = false,
|
||||
pending = false,
|
||||
answeredResponse = null,
|
||||
threadId,
|
||||
onSubmit,
|
||||
}: {
|
||||
request: HumanInputRequest;
|
||||
disabled?: boolean;
|
||||
pending?: boolean;
|
||||
answeredResponse?: HumanInputResponse | null;
|
||||
threadId?: string;
|
||||
onSubmit?: (
|
||||
response: HumanInputResponse,
|
||||
) => HumanInputSubmitResult | Promise<HumanInputSubmitResult>;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const markdownComponents = useMemo(
|
||||
() => ({ a: createMarkdownLinkComponent(threadId) }),
|
||||
[threadId],
|
||||
);
|
||||
const [text, setText] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [isComposing, setIsComposing] = useState(false);
|
||||
@ -379,6 +386,7 @@ export function HumanInputCard({
|
||||
<MarkdownContent
|
||||
content={request.context}
|
||||
isLoading={false}
|
||||
components={markdownComponents}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
@ -405,7 +413,11 @@ export function HumanInputCard({
|
||||
</div>
|
||||
|
||||
<div className="text-foreground text-sm leading-6">
|
||||
<MarkdownContent content={request.question} isLoading={false} />
|
||||
<MarkdownContent
|
||||
content={request.question}
|
||||
isLoading={false}
|
||||
components={markdownComponents}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isForm ? (
|
||||
|
||||
@ -1,6 +1,15 @@
|
||||
import type { AnchorHTMLAttributes, ComponentProps } from "react";
|
||||
import {
|
||||
type AnchorHTMLAttributes,
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
useContext,
|
||||
} from "react";
|
||||
|
||||
import { resolveMarkdownArtifactURL } from "@/core/artifacts/utils";
|
||||
import {
|
||||
isPotentialRelativeArtifactReference,
|
||||
resolveMarkdownArtifactURL,
|
||||
resolveRelativeArtifactURL,
|
||||
} from "@/core/artifacts/utils";
|
||||
import {
|
||||
retrievalSourceId,
|
||||
type RetrievalSource,
|
||||
@ -86,9 +95,24 @@ function isExternalUrl(href: string | undefined): boolean {
|
||||
return /^https?:\/\//.test(href);
|
||||
}
|
||||
|
||||
/**
|
||||
* The current thread's artifact paths, provided by the message list. Inside
|
||||
* it, a link rendered with a `threadId` resolves relative destinations
|
||||
* (`report.md`, `./out/a.md`) to the artifact they name.
|
||||
*
|
||||
* This is a context rather than a factory argument because Streamdown
|
||||
* memoizes its root without comparing `components`: a link renderer created
|
||||
* before the artifact list arrives would keep the stale list, while a context
|
||||
* change still reaches it.
|
||||
*/
|
||||
export const MessageArtifactPathsContext = createContext<
|
||||
readonly string[] | null
|
||||
>(null);
|
||||
|
||||
/**
|
||||
* Builds the `a` renderer shared by message content and generic markdown.
|
||||
* Passing a `threadId` also resolves `/mnt/` artifact links; without it those
|
||||
* Passing a `threadId` also resolves `/mnt/` artifact links, plus relative
|
||||
* artifact links inside a `MessageArtifactPathsContext`; without it those
|
||||
* links fall through to the default external-link handling.
|
||||
*/
|
||||
export function createMarkdownLinkComponent(
|
||||
@ -99,6 +123,7 @@ export function createMarkdownLinkComponent(
|
||||
href,
|
||||
...props
|
||||
}: AnchorHTMLAttributes<HTMLAnchorElement>) {
|
||||
const artifactPaths = useContext(MessageArtifactPathsContext);
|
||||
if (/^#(?:user-content-)?knowledge-source-/.test(href ?? "")) {
|
||||
const id = retrievalSourceId(href);
|
||||
const source = knowledgeSources.find((item) => item.id === id);
|
||||
@ -142,28 +167,55 @@ export function createMarkdownLinkComponent(
|
||||
);
|
||||
}
|
||||
}
|
||||
// Artifact links (`/mnt/…`, or relative inside the message list) open in a
|
||||
// new tab, styled like any other link so they read as clickable.
|
||||
let resolvedHref = href;
|
||||
let opensArtifact = false;
|
||||
if (threadId && href?.startsWith("/mnt/")) {
|
||||
return (
|
||||
<a
|
||||
{...props}
|
||||
href={resolveMarkdownArtifactURL(href, threadId)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
/>
|
||||
resolvedHref = resolveMarkdownArtifactURL(href, threadId);
|
||||
opensArtifact = true;
|
||||
} else if (
|
||||
threadId &&
|
||||
artifactPaths &&
|
||||
href &&
|
||||
isPotentialRelativeArtifactReference(href)
|
||||
) {
|
||||
const artifactURL = resolveRelativeArtifactURL(
|
||||
href,
|
||||
threadId,
|
||||
artifactPaths,
|
||||
);
|
||||
if (!artifactURL) {
|
||||
// Left as-is, the browser would resolve the href against the chat
|
||||
// route (/workspace/chats/<file>) and open a broken "thread".
|
||||
const { className, children } = props;
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-muted-foreground underline decoration-dotted underline-offset-2",
|
||||
className,
|
||||
)}
|
||||
title={`No single artifact in this conversation matches ${href}`}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
resolvedHref = artifactURL;
|
||||
opensArtifact = true;
|
||||
}
|
||||
const { className, target, rel, ...rest } = props;
|
||||
const external = isExternalUrl(href);
|
||||
const opensNewTab = opensArtifact || isExternalUrl(href);
|
||||
return (
|
||||
<a
|
||||
{...rest}
|
||||
href={href}
|
||||
href={resolvedHref}
|
||||
className={cn(
|
||||
"text-primary decoration-primary/30 hover:decoration-primary/60 underline underline-offset-2 transition-colors",
|
||||
className,
|
||||
)}
|
||||
target={target ?? (external ? "_blank" : undefined)}
|
||||
rel={rel ?? (external ? "noopener noreferrer" : undefined)}
|
||||
target={target ?? (opensNewTab ? "_blank" : undefined)}
|
||||
rel={rel ?? (opensNewTab ? "noopener noreferrer" : undefined)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@ -53,7 +53,11 @@ import { FlipDisplay } from "../flip-display";
|
||||
import { Tooltip } from "../tooltip";
|
||||
|
||||
import { MarkdownContent } from "./markdown-content";
|
||||
import { isSafeHref, UnsafeLink } from "./markdown-link";
|
||||
import {
|
||||
createMarkdownLinkComponent,
|
||||
isSafeHref,
|
||||
UnsafeLink,
|
||||
} from "./markdown-link";
|
||||
import { MessageReasoning } from "./message-reasoning";
|
||||
import { ToolCallDetails } from "./tool-call-details";
|
||||
|
||||
@ -81,6 +85,10 @@ function MessageGroupComponent({
|
||||
toolArtifacts,
|
||||
}: MessageGroupProps) {
|
||||
const { t } = useI18n();
|
||||
const markdownComponents = useMemo(
|
||||
() => ({ a: createMarkdownLinkComponent(threadId) }),
|
||||
[threadId],
|
||||
);
|
||||
const [showAbove, setShowAbove] = useState(
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
|
||||
);
|
||||
@ -303,7 +311,13 @@ function MessageGroupComponent({
|
||||
<ChainOfThoughtStep
|
||||
key={step.id}
|
||||
icon={MessageSquareTextIcon}
|
||||
label={<MarkdownContent content={step.content} isLoading={isLoading} />}
|
||||
label={
|
||||
<MarkdownContent
|
||||
content={step.content}
|
||||
isLoading={isLoading}
|
||||
components={markdownComponents}
|
||||
/>
|
||||
}
|
||||
></ChainOfThoughtStep>
|
||||
);
|
||||
|
||||
@ -324,6 +338,7 @@ function MessageGroupComponent({
|
||||
<MarkdownContent
|
||||
content={step.reasoning ?? ""}
|
||||
isLoading={isLoading}
|
||||
components={markdownComponents}
|
||||
/>
|
||||
}
|
||||
></ChainOfThoughtStep>,
|
||||
@ -479,6 +494,7 @@ function MessageGroupComponent({
|
||||
<MarkdownContent
|
||||
content={lastReasoningStep.reasoning ?? ""}
|
||||
isLoading={isLoading}
|
||||
components={markdownComponents}
|
||||
/>
|
||||
}
|
||||
></ChainOfThoughtStep>
|
||||
@ -501,7 +517,11 @@ function MessageGroupComponent({
|
||||
{processingPanel}
|
||||
{clarificationTextSteps.map((step) => (
|
||||
<div key={step.id} className="w-full">
|
||||
<MarkdownContent content={step.content} isLoading={isLoading} />
|
||||
<MarkdownContent
|
||||
content={step.content}
|
||||
isLoading={isLoading}
|
||||
components={markdownComponents}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
|
||||
@ -71,7 +71,10 @@ import { Tooltip } from "../tooltip";
|
||||
|
||||
import { KnowledgeScopeSummary } from "./knowledge-scope-summary";
|
||||
import { MarkdownContent } from "./markdown-content";
|
||||
import { createMarkdownLinkComponent } from "./markdown-link";
|
||||
import {
|
||||
createMarkdownLinkComponent,
|
||||
MessageArtifactPathsContext,
|
||||
} from "./markdown-link";
|
||||
import { MessageReasoning } from "./message-reasoning";
|
||||
|
||||
function FeedbackButtons({
|
||||
@ -651,12 +654,14 @@ function MessageContent_({
|
||||
{reasoningContent}
|
||||
</MessageReasoning>
|
||||
)}
|
||||
<MarkdownContent
|
||||
content={contentToDisplay}
|
||||
isLoading={isLoading}
|
||||
className="my-3"
|
||||
components={components}
|
||||
/>
|
||||
<MessageArtifactPathsContext.Provider value={artifactPaths}>
|
||||
<MarkdownContent
|
||||
content={contentToDisplay}
|
||||
isLoading={isLoading}
|
||||
className="my-3"
|
||||
components={components}
|
||||
/>
|
||||
</MessageArtifactPathsContext.Provider>
|
||||
<CitationSourcesPanel sources={citationSources} />
|
||||
<KnowledgeSourcesPanel content={contentToDisplay} />
|
||||
{message.type === "ai" && showWorkspaceChanges && (
|
||||
|
||||
@ -26,7 +26,11 @@ import {
|
||||
} from "@/components/ai-elements/conversation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { KnowledgeSourcesProvider } from "@/components/workspace/citations/knowledge-source";
|
||||
import { extractArtifactsFromThread } from "@/core/artifacts/utils";
|
||||
import {
|
||||
areStringArraysEqual,
|
||||
collectThreadArtifactPaths,
|
||||
extractArtifactsFromThread,
|
||||
} from "@/core/artifacts/utils";
|
||||
import {
|
||||
createKnowledgeSourceReader,
|
||||
type RetrievalSource,
|
||||
@ -112,6 +116,10 @@ import {
|
||||
type HumanInputSubmitResult,
|
||||
} from "./human-input-card";
|
||||
import { MarkdownContent } from "./markdown-content";
|
||||
import {
|
||||
createMarkdownLinkComponent,
|
||||
MessageArtifactPathsContext,
|
||||
} from "./markdown-link";
|
||||
import { MessageGroup, getMessageGroupReasoningMessage } from "./message-group";
|
||||
import { MessageListItem } from "./message-list-item";
|
||||
import {
|
||||
@ -137,19 +145,12 @@ type SettledStreamMetadataState = {
|
||||
snapshot: StreamMetadataSnapshot;
|
||||
};
|
||||
|
||||
function sameStrings(previous: readonly string[], next: readonly string[]) {
|
||||
return (
|
||||
previous.length === next.length &&
|
||||
previous.every((value, index) => value === next[index])
|
||||
);
|
||||
}
|
||||
|
||||
function useStableArtifactPaths(paths: readonly string[] | undefined) {
|
||||
const previousPathsRef = useRef<readonly string[]>(EMPTY_ARTIFACT_PATHS);
|
||||
return useMemo(() => {
|
||||
const nextPaths = paths ?? EMPTY_ARTIFACT_PATHS;
|
||||
const previousPaths = previousPathsRef.current;
|
||||
if (sameStrings(previousPaths, nextPaths)) {
|
||||
if (areStringArraysEqual(previousPaths, nextPaths)) {
|
||||
return previousPaths;
|
||||
}
|
||||
previousPathsRef.current = nextPaths;
|
||||
@ -1146,8 +1147,16 @@ export function MessageList({
|
||||
],
|
||||
);
|
||||
|
||||
const artifactPaths = useStableArtifactPaths(
|
||||
extractArtifactsFromThread(thread),
|
||||
const stateArtifactPaths = extractArtifactsFromThread(thread);
|
||||
const threadArtifactPaths = useMemo(
|
||||
() => collectThreadArtifactPaths(stateArtifactPaths, messages),
|
||||
[stateArtifactPaths, messages],
|
||||
);
|
||||
const artifactPaths = useStableArtifactPaths(threadArtifactPaths);
|
||||
// Message text rendered outside MessageListItem resolves artifact links too.
|
||||
const threadLinkComponents = useMemo(
|
||||
() => ({ a: createMarkdownLinkComponent(threadId) }),
|
||||
[threadId],
|
||||
);
|
||||
|
||||
if (thread.isThreadLoading && messages.length === 0) {
|
||||
@ -1418,28 +1427,33 @@ export function MessageList({
|
||||
group,
|
||||
groupIndex,
|
||||
<div className="w-full">
|
||||
<HumanInputCard
|
||||
answeredResponse={answeredResponse}
|
||||
disabled={
|
||||
thread.isLoading ||
|
||||
pending ||
|
||||
Boolean(answeredResponse) ||
|
||||
humanInputState.latestOpenRequestId !==
|
||||
humanInputRequest.request_id ||
|
||||
!onSubmitHumanInput
|
||||
}
|
||||
pending={pending}
|
||||
request={humanInputRequest}
|
||||
onSubmit={
|
||||
onSubmitHumanInput
|
||||
? (response) =>
|
||||
handleSubmitHumanInput(
|
||||
humanInputRequest,
|
||||
response,
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<MessageArtifactPathsContext.Provider
|
||||
value={artifactPaths}
|
||||
>
|
||||
<HumanInputCard
|
||||
answeredResponse={answeredResponse}
|
||||
disabled={
|
||||
thread.isLoading ||
|
||||
pending ||
|
||||
Boolean(answeredResponse) ||
|
||||
humanInputState.latestOpenRequestId !==
|
||||
humanInputRequest.request_id ||
|
||||
!onSubmitHumanInput
|
||||
}
|
||||
pending={pending}
|
||||
request={humanInputRequest}
|
||||
threadId={threadId}
|
||||
onSubmit={
|
||||
onSubmitHumanInput
|
||||
? (response) =>
|
||||
handleSubmitHumanInput(
|
||||
humanInputRequest,
|
||||
response,
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</MessageArtifactPathsContext.Provider>
|
||||
{renderTokenUsage({
|
||||
messages: group.messages,
|
||||
turnUsageMessages,
|
||||
@ -1453,10 +1467,15 @@ export function MessageList({
|
||||
group,
|
||||
groupIndex,
|
||||
<div className="w-full">
|
||||
<MarkdownContent
|
||||
content={extractContentFromMessage(message)}
|
||||
isLoading={thread.isLoading}
|
||||
/>
|
||||
<MessageArtifactPathsContext.Provider
|
||||
value={artifactPaths}
|
||||
>
|
||||
<MarkdownContent
|
||||
content={extractContentFromMessage(message)}
|
||||
isLoading={thread.isLoading}
|
||||
components={threadLinkComponents}
|
||||
/>
|
||||
</MessageArtifactPathsContext.Provider>
|
||||
{renderTokenUsage({
|
||||
messages: group.messages,
|
||||
turnUsageMessages,
|
||||
@ -1494,11 +1513,16 @@ export function MessageList({
|
||||
groupIndex,
|
||||
<div className="w-full">
|
||||
{group.messages[0] && hasContent(group.messages[0]) && (
|
||||
<MarkdownContent
|
||||
content={extractContentFromMessage(group.messages[0])}
|
||||
isLoading={thread.isLoading}
|
||||
className="mb-4"
|
||||
/>
|
||||
<MessageArtifactPathsContext.Provider
|
||||
value={artifactPaths}
|
||||
>
|
||||
<MarkdownContent
|
||||
content={extractContentFromMessage(group.messages[0])}
|
||||
isLoading={thread.isLoading}
|
||||
className="mb-4"
|
||||
components={threadLinkComponents}
|
||||
/>
|
||||
</MessageArtifactPathsContext.Provider>
|
||||
)}
|
||||
<ArtifactFileList
|
||||
archiveDownloadsEnabled={
|
||||
@ -1547,20 +1571,25 @@ export function MessageList({
|
||||
)) {
|
||||
if (hasReasoning(message)) {
|
||||
results.push(
|
||||
<MessageGroup
|
||||
<MessageArtifactPathsContext.Provider
|
||||
key={"thinking-group-" + message.id}
|
||||
messages={[message]}
|
||||
durationSeconds={
|
||||
reasoningDurations.get(message)?.durationSeconds
|
||||
}
|
||||
isLoading={groupIsLoading}
|
||||
deferBrowserPreviews={thread.isLoading}
|
||||
tokenDebugSteps={getTokenDebugStepsForMessages([
|
||||
message,
|
||||
])}
|
||||
showTokenDebugSummaries={showTokenDebugSummaries}
|
||||
toolArtifacts={thread.values?.tool_artifacts}
|
||||
/>,
|
||||
value={artifactPaths}
|
||||
>
|
||||
<MessageGroup
|
||||
messages={[message]}
|
||||
durationSeconds={
|
||||
reasoningDurations.get(message)?.durationSeconds
|
||||
}
|
||||
isLoading={groupIsLoading}
|
||||
deferBrowserPreviews={thread.isLoading}
|
||||
tokenDebugSteps={getTokenDebugStepsForMessages([
|
||||
message,
|
||||
])}
|
||||
showTokenDebugSummaries={showTokenDebugSummaries}
|
||||
threadId={threadId}
|
||||
toolArtifacts={thread.values?.tool_artifacts}
|
||||
/>
|
||||
</MessageArtifactPathsContext.Provider>,
|
||||
);
|
||||
} else if (message.id) {
|
||||
subagentDebugMessageIds.push(message.id);
|
||||
@ -1576,15 +1605,22 @@ export function MessageList({
|
||||
continue;
|
||||
}
|
||||
results.push(
|
||||
<SubtaskCard
|
||||
<MessageArtifactPathsContext.Provider
|
||||
key={"task-group-" + taskId}
|
||||
taskId={taskId}
|
||||
threadId={threadId}
|
||||
runId={(message as { run_id?: string }).run_id}
|
||||
isLoading={groupIsLoading}
|
||||
fallbackTask={fallbackTask}
|
||||
resultMessage={findSubtaskResultMessage(group, taskId)}
|
||||
/>,
|
||||
value={artifactPaths}
|
||||
>
|
||||
<SubtaskCard
|
||||
taskId={taskId}
|
||||
threadId={threadId}
|
||||
runId={(message as { run_id?: string }).run_id}
|
||||
isLoading={groupIsLoading}
|
||||
fallbackTask={fallbackTask}
|
||||
resultMessage={findSubtaskResultMessage(
|
||||
group,
|
||||
taskId,
|
||||
)}
|
||||
/>
|
||||
</MessageArtifactPathsContext.Provider>,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1606,20 +1642,23 @@ export function MessageList({
|
||||
group,
|
||||
groupIndex,
|
||||
<div className="w-full">
|
||||
<MessageGroup
|
||||
messages={group.messages}
|
||||
durationSeconds={
|
||||
reasoningDurations.values().next().value?.durationSeconds
|
||||
}
|
||||
isLoading={groupIsLoading}
|
||||
deferBrowserPreviews={thread.isLoading}
|
||||
threadId={threadId}
|
||||
tokenDebugSteps={getTokenDebugStepsForMessages(
|
||||
group.messages,
|
||||
)}
|
||||
showTokenDebugSummaries={showTokenDebugSummaries}
|
||||
toolArtifacts={thread.values?.tool_artifacts}
|
||||
/>
|
||||
<MessageArtifactPathsContext.Provider value={artifactPaths}>
|
||||
<MessageGroup
|
||||
messages={group.messages}
|
||||
durationSeconds={
|
||||
reasoningDurations.values().next().value
|
||||
?.durationSeconds
|
||||
}
|
||||
isLoading={groupIsLoading}
|
||||
deferBrowserPreviews={thread.isLoading}
|
||||
threadId={threadId}
|
||||
tokenDebugSteps={getTokenDebugStepsForMessages(
|
||||
group.messages,
|
||||
)}
|
||||
showTokenDebugSummaries={showTokenDebugSummaries}
|
||||
toolArtifacts={thread.values?.tool_artifacts}
|
||||
/>
|
||||
</MessageArtifactPathsContext.Provider>
|
||||
{renderTokenUsage({
|
||||
messages: group.messages,
|
||||
turnUsageMessages,
|
||||
|
||||
@ -258,7 +258,11 @@ export function SubtaskCard({
|
||||
(step.tool_name ?? t.subtasks[task.status])
|
||||
) : (
|
||||
<div className="text-muted-foreground line-clamp-3 text-sm">
|
||||
<MarkdownContent content={step.text} isLoading={false} />
|
||||
<MarkdownContent
|
||||
content={step.text}
|
||||
isLoading={false}
|
||||
components={reportComponents}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,9 +1,22 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
|
||||
import { getBackendBaseURL } from "../config";
|
||||
import { extractPresentFilesFromMessage } from "../messages/utils";
|
||||
import { isStaticWebsiteOnly } from "../static-mode";
|
||||
import type { AgentThreadState } from "../threads";
|
||||
|
||||
const EMPTY_ARTIFACT_PATHS: readonly string[] = [];
|
||||
|
||||
export function areStringArraysEqual(
|
||||
previous: readonly string[],
|
||||
next: readonly string[],
|
||||
) {
|
||||
return (
|
||||
previous.length === next.length &&
|
||||
previous.every((value, index) => value === next[index])
|
||||
);
|
||||
}
|
||||
|
||||
function decodePathSegment(segment: string) {
|
||||
try {
|
||||
return decodeURIComponent(segment);
|
||||
@ -91,6 +104,30 @@ export function extractArtifactsFromThread(thread: {
|
||||
return thread.values.artifacts ?? EMPTY_ARTIFACT_PATHS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the `/mnt/` paths passed to `present_files` in `messages` to the
|
||||
* thread's artifact list. A reopened thread rebuilds its values from the
|
||||
* Gateway's POST /history head, which carries messages but not `artifacts`;
|
||||
* the backend fills `artifacts` from those same `present_files` calls, so the
|
||||
* messages are enough to recover the list. Returns `stateArtifacts` itself
|
||||
* when no message presents files.
|
||||
*/
|
||||
export function collectThreadArtifactPaths(
|
||||
stateArtifacts: readonly string[],
|
||||
messages: readonly Message[],
|
||||
): readonly string[] {
|
||||
const presented = messages
|
||||
.flatMap((message) => extractPresentFilesFromMessage(message))
|
||||
.filter(
|
||||
(path): path is string =>
|
||||
typeof path === "string" && path.startsWith("/mnt/"),
|
||||
);
|
||||
if (presented.length === 0) {
|
||||
return stateArtifacts;
|
||||
}
|
||||
return [...new Set([...stateArtifacts, ...presented])];
|
||||
}
|
||||
|
||||
export function resolveArtifactURL(absolutePath: string, threadId: string) {
|
||||
if (isStaticWebsiteOnly()) {
|
||||
return staticDemoArtifactURL({ filepath: absolutePath, threadId });
|
||||
@ -153,6 +190,73 @@ export function resolveArtifactImageURL(
|
||||
}
|
||||
}
|
||||
|
||||
function parseRelativeArtifactReference(src: string) {
|
||||
const { path: relativePath, suffix } = splitPathSuffix(src);
|
||||
const normalizedPath = relativePath.replace(/^(?:\.\/)+/, "");
|
||||
const decodedPath = decodeRelativeArtifactPath(normalizedPath);
|
||||
if (
|
||||
!normalizedPath ||
|
||||
normalizedPath.startsWith("/") ||
|
||||
/^[a-z][a-z\d+.-]*:/i.test(normalizedPath) ||
|
||||
normalizedPath.startsWith("//") ||
|
||||
normalizedPath.split("/").includes("..") ||
|
||||
decodedPath.split("/").includes("..")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { decodedPath, suffix };
|
||||
}
|
||||
|
||||
/**
|
||||
* True for a scheme-less relative path, including invalid artifact paths such
|
||||
* as `../report.md`. Message links in this category must either resolve to a
|
||||
* known artifact or render inert instead of navigating relative to the chat
|
||||
* route.
|
||||
*/
|
||||
export function isPotentialRelativeArtifactReference(src: string) {
|
||||
const { path } = splitPathSuffix(src);
|
||||
return (
|
||||
Boolean(path) &&
|
||||
!path.startsWith("/") &&
|
||||
!path.startsWith("//") &&
|
||||
!/^[a-z][a-z\d+.-]*:/i.test(path)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* True for a scheme-less relative reference (`report.md`, `./out/a.png`)
|
||||
* that could name a thread artifact: no scheme, no leading `/`, no `..`
|
||||
* segment, and a non-empty path (so `#anchor` and `?query` are excluded).
|
||||
*/
|
||||
export function isRelativeArtifactReference(src: string) {
|
||||
return parseRelativeArtifactReference(src) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a relative reference to this thread's artifact URL when it
|
||||
* suffix-matches exactly one artifact path. Returns `null` when `src` is not
|
||||
* a relative reference or when zero or several artifacts match.
|
||||
*/
|
||||
export function resolveRelativeArtifactURL(
|
||||
src: string,
|
||||
threadId: string,
|
||||
artifactPaths: readonly string[],
|
||||
) {
|
||||
const reference = parseRelativeArtifactReference(src);
|
||||
if (!reference) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const matches = artifactPaths.filter((path) =>
|
||||
path.endsWith(`/${reference.decodedPath}`),
|
||||
);
|
||||
if (matches.length !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${resolveArtifactURL(matches[0]!, threadId)}${reference.suffix}`;
|
||||
}
|
||||
|
||||
export function resolveMessageImageURL(
|
||||
src: string,
|
||||
threadId: string,
|
||||
@ -162,27 +266,7 @@ export function resolveMessageImageURL(
|
||||
return resolveMarkdownArtifactURL(src, threadId);
|
||||
}
|
||||
|
||||
const { path: relativePath, suffix } = splitPathSuffix(src);
|
||||
const normalizedPath = relativePath.replace(/^(?:\.\/)+/, "");
|
||||
const decodedNormalizedPath = decodeRelativeArtifactPath(normalizedPath);
|
||||
if (
|
||||
!normalizedPath ||
|
||||
normalizedPath.startsWith("/") ||
|
||||
/^[a-z][a-z\d+.-]*:/i.test(normalizedPath) ||
|
||||
normalizedPath.startsWith("//") ||
|
||||
normalizedPath.split("/").includes("..")
|
||||
) {
|
||||
return src;
|
||||
}
|
||||
|
||||
const matches = artifactPaths.filter((path) =>
|
||||
path.endsWith(`/${decodedNormalizedPath}`),
|
||||
);
|
||||
if (matches.length !== 1) {
|
||||
return src;
|
||||
}
|
||||
|
||||
return `${resolveArtifactURL(matches[0]!, threadId)}${suffix}`;
|
||||
return resolveRelativeArtifactURL(src, threadId, artifactPaths) ?? src;
|
||||
}
|
||||
|
||||
function staticDemoArtifactURL({
|
||||
|
||||
205
frontend/tests/e2e/relative-artifact-link.spec.ts
Normal file
205
frontend/tests/e2e/relative-artifact-link.spec.ts
Normal file
@ -0,0 +1,205 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { mockLangGraphAPI } from "./utils/mock-api";
|
||||
|
||||
// A relative link in an assistant message (`[Report](final-report.md)`) must
|
||||
// open the thread artifact it names. Left as a relative href, the browser
|
||||
// resolves it to /workspace/chats/final-report.md and the chat page then loads
|
||||
// the file name as a thread id.
|
||||
const THREAD_ID = "00000000-0000-0000-0000-000000007315";
|
||||
const REPORT_NAME = "final-report.md";
|
||||
const REPORT_PATH = `/mnt/user-data/outputs/${REPORT_NAME}`;
|
||||
const REPORT_API = `/api/threads/${THREAD_ID}/artifacts${REPORT_PATH}`;
|
||||
|
||||
test("relative report link in a message opens the thread artifact", async ({
|
||||
page,
|
||||
context,
|
||||
}, testInfo) => {
|
||||
mockLangGraphAPI(page, {
|
||||
threads: [
|
||||
{
|
||||
thread_id: THREAD_ID,
|
||||
title: "Relative artifact link",
|
||||
artifacts: [REPORT_PATH],
|
||||
messages: [
|
||||
{
|
||||
type: "human",
|
||||
id: "msg-human-rel-link",
|
||||
content: [{ type: "text", text: "Write the full report." }],
|
||||
},
|
||||
{
|
||||
type: "ai",
|
||||
id: "msg-ai-rel-link",
|
||||
content: `The report is ready: [Final report](${REPORT_NAME}). See also [missing appendix](appendix.md).`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const artifactRequests: string[] = [];
|
||||
const chatNavigations: string[] = [];
|
||||
await context.route(
|
||||
(url) => url.pathname === REPORT_API,
|
||||
(route) => {
|
||||
artifactRequests.push(route.request().url());
|
||||
return route.fulfill({
|
||||
contentType: "text/markdown; charset=utf-8",
|
||||
body: "# Final report\n\nREPORT_BODY_MARKER",
|
||||
});
|
||||
},
|
||||
);
|
||||
context.on("request", (request) => {
|
||||
if (new URL(request.url()).pathname.endsWith(`/chats/${REPORT_NAME}`)) {
|
||||
chatNavigations.push(request.url());
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto(`/workspace/chats/${THREAD_ID}`);
|
||||
|
||||
const reportLink = page.getByRole("link", { name: "Final report" });
|
||||
await expect(reportLink).toBeVisible({ timeout: 30_000 });
|
||||
await expect(reportLink).toHaveAttribute("href", REPORT_API);
|
||||
await expect(reportLink).toHaveAttribute("target", "_blank");
|
||||
|
||||
// A relative link that names no artifact renders as text, not an anchor.
|
||||
await expect(page.getByText("missing appendix")).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("link", { name: "missing appendix" }),
|
||||
).toHaveCount(0);
|
||||
await page.screenshot({ path: testInfo.outputPath("chat-message.png") });
|
||||
|
||||
const popupPromise = context.waitForEvent("page");
|
||||
await reportLink.click();
|
||||
const popup = await popupPromise;
|
||||
await expect(popup.locator("body")).toContainText("REPORT_BODY_MARKER");
|
||||
expect(new URL(popup.url()).pathname).toBe(REPORT_API);
|
||||
|
||||
expect(artifactRequests.length).toBeGreaterThan(0);
|
||||
expect(chatNavigations).toEqual([]);
|
||||
await expect(page).toHaveURL(new RegExp(`/workspace/chats/${THREAD_ID}$`));
|
||||
});
|
||||
|
||||
// The present_files message text is rendered outside MessageListItem.
|
||||
test("links in the present_files message text resolve to artifacts", async ({
|
||||
page,
|
||||
}) => {
|
||||
mockLangGraphAPI(page, {
|
||||
threads: [
|
||||
{
|
||||
thread_id: THREAD_ID,
|
||||
title: "Relative artifact link (present_files)",
|
||||
artifacts: [REPORT_PATH],
|
||||
messages: [
|
||||
{
|
||||
type: "human",
|
||||
id: "msg-human-rel-link-present",
|
||||
content: [{ type: "text", text: "Write the full report." }],
|
||||
},
|
||||
{
|
||||
type: "ai",
|
||||
id: "msg-ai-rel-link-present",
|
||||
content: `Done: [relative link](${REPORT_NAME}), [absolute link](${REPORT_PATH}).`,
|
||||
tool_calls: [
|
||||
{
|
||||
id: "present-rel-link",
|
||||
name: "present_files",
|
||||
args: { filepaths: [REPORT_PATH] },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await page.goto(`/workspace/chats/${THREAD_ID}`);
|
||||
for (const name of ["relative link", "absolute link"]) {
|
||||
const link = page.getByRole("link", { name });
|
||||
await expect(link).toBeVisible({ timeout: 30_000 });
|
||||
await expect(link).toHaveAttribute("href", REPORT_API);
|
||||
await expect(link).toHaveAttribute("target", "_blank");
|
||||
}
|
||||
});
|
||||
|
||||
// A reopened thread rebuilds its values from the Gateway's POST /history head,
|
||||
// which carries messages but not `artifacts`. Links and the artifact panel
|
||||
// must still find the files from the present_files call in the messages.
|
||||
test("reopened thread without state artifacts still resolves links and lists files", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const files = [
|
||||
REPORT_PATH,
|
||||
"/mnt/user-data/outputs/change-notes.md",
|
||||
"/mnt/user-data/outputs/calculations.zip",
|
||||
];
|
||||
mockLangGraphAPI(page, {
|
||||
threads: [
|
||||
{
|
||||
thread_id: THREAD_ID,
|
||||
title: "Reopened thread",
|
||||
// No `artifacts`: mirrors the real /history head.
|
||||
messages: [
|
||||
{
|
||||
type: "human",
|
||||
id: "msg-human-reopened",
|
||||
content: [{ type: "text", text: "Deliver the files." }],
|
||||
},
|
||||
{
|
||||
type: "ai",
|
||||
id: "msg-ai-present-reopened",
|
||||
content: "",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "present-reopened",
|
||||
name: "present_files",
|
||||
args: { filepaths: files },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "tool",
|
||||
id: "msg-tool-reopened",
|
||||
name: "present_files",
|
||||
tool_call_id: "present-reopened",
|
||||
content: "Successfully presented files",
|
||||
},
|
||||
{
|
||||
type: "ai",
|
||||
id: "msg-ai-reopened",
|
||||
content: [
|
||||
"All three deliverables are ready.",
|
||||
"### Downloads",
|
||||
`- [Final report](./${REPORT_NAME})`,
|
||||
"- [Change notes](./change-notes.md)",
|
||||
"- [Calculation package](./calculations.zip)",
|
||||
].join("\n\n"),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await page.goto(`/workspace/chats/${THREAD_ID}`);
|
||||
for (const [name, path] of [
|
||||
["Final report", files[0]],
|
||||
["Change notes", files[1]],
|
||||
["Calculation package", files[2]],
|
||||
] as const) {
|
||||
const link = page.getByRole("link", { name, exact: true });
|
||||
await expect(link).toBeVisible({ timeout: 30_000 });
|
||||
await expect(link).toHaveAttribute(
|
||||
"href",
|
||||
`/api/threads/${THREAD_ID}/artifacts${path}`,
|
||||
);
|
||||
}
|
||||
await page.screenshot({ path: testInfo.outputPath("reopened-message.png") });
|
||||
|
||||
// The artifact panel (and its header button) is fed the same way.
|
||||
await page.getByTestId("artifact-trigger").click();
|
||||
const panel = page.locator("#artifacts");
|
||||
for (const file of ["change-notes.md", "calculations.zip"]) {
|
||||
await expect(panel.getByText(file, { exact: true })).toBeVisible();
|
||||
}
|
||||
await page.screenshot({ path: testInfo.outputPath("reopened-panel.png") });
|
||||
});
|
||||
@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, rs } from "@rstest/core";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
|
||||
import { HumanInputCard } from "@/components/workspace/messages/human-input-card";
|
||||
import { MessageArtifactPathsContext } from "@/components/workspace/messages/markdown-link";
|
||||
import { I18nContext } from "@/core/i18n/context";
|
||||
import { enUS } from "@/core/i18n/locales/en-US";
|
||||
import type { HumanInputRequest } from "@/core/messages/human-input";
|
||||
@ -35,6 +36,33 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("HumanInputCard form validation (DOM)", () => {
|
||||
it("resolves relative artifact links in its context and question", () => {
|
||||
render(
|
||||
<I18nContext.Provider
|
||||
value={{ locale: "en-US", setLocale: () => undefined, t: enUS }}
|
||||
>
|
||||
<MessageArtifactPathsContext.Provider
|
||||
value={["/mnt/user-data/outputs/report.md"]}
|
||||
>
|
||||
<HumanInputCard
|
||||
request={{
|
||||
...formRequest,
|
||||
context: "Review the [draft](report.md).",
|
||||
question: "Approve the [report](./report.md)?",
|
||||
}}
|
||||
threadId="thread-1"
|
||||
/>
|
||||
</MessageArtifactPathsContext.Provider>
|
||||
</I18nContext.Provider>,
|
||||
);
|
||||
|
||||
for (const name of ["draft", "report"]) {
|
||||
expect(screen.getByRole("link", { name }).getAttribute("href")).toBe(
|
||||
"/api/threads/thread-1/artifacts/mnt/user-data/outputs/report.md",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the error node mounted while another field is still invalid", () => {
|
||||
renderCard();
|
||||
|
||||
|
||||
@ -5,6 +5,7 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
import {
|
||||
createMarkdownLinkComponent,
|
||||
isSafeHref,
|
||||
MessageArtifactPathsContext,
|
||||
} from "@/components/workspace/messages/markdown-link";
|
||||
|
||||
describe("isSafeHref", () => {
|
||||
@ -28,6 +29,7 @@ describe("isSafeHref", () => {
|
||||
|
||||
it("rejects executable, local, and protocol-relative URLs", () => {
|
||||
expect(isSafeHref("javascript:alert(1)")).toBe(false);
|
||||
expect(isSafeHref("java\tscript:alert(1)")).toBe(false);
|
||||
expect(isSafeHref("data:text/html,<script>alert(1)</script>")).toBe(false);
|
||||
expect(isSafeHref("file:///etc/passwd")).toBe(false);
|
||||
expect(isSafeHref("//example.com/path")).toBe(false);
|
||||
@ -103,3 +105,104 @@ describe("MarkdownLink rendering", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Inside the message list a relative link must open the thread artifact it
|
||||
// names. Left as a relative href, the browser resolves it against the chat
|
||||
// route (/workspace/chats/<file>), which then loads the file name as a thread.
|
||||
describe("MarkdownLink relative artifact links", () => {
|
||||
const artifacts = [
|
||||
"/mnt/user-data/outputs/final report.md",
|
||||
"/mnt/user-data/outputs/first/shared.md",
|
||||
"/mnt/user-data/outputs/second/shared.md",
|
||||
"/mnt/user-data/outputs/notes.md",
|
||||
];
|
||||
const MessageLink = createMarkdownLinkComponent("thread-1");
|
||||
const reportURL =
|
||||
"/api/threads/thread-1/artifacts/mnt/user-data/outputs/final%20report.md";
|
||||
const notesURL =
|
||||
"/api/threads/thread-1/artifacts/mnt/user-data/outputs/notes.md";
|
||||
|
||||
function render(href: string, paths: readonly string[] | null = artifacts) {
|
||||
const link = createElement(MessageLink, { href }, "doc");
|
||||
return renderToStaticMarkup(
|
||||
paths === null
|
||||
? link
|
||||
: createElement(
|
||||
MessageArtifactPathsContext.Provider,
|
||||
{ value: paths },
|
||||
link,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
it("rewrites a uniquely matching relative link to the artifact URL", () => {
|
||||
// Markdown destinations reach the renderer percent-encoded.
|
||||
const html = render("final%20report.md");
|
||||
expect(html).toContain(`href="${reportURL}"`);
|
||||
expect(html).toContain('target="_blank"');
|
||||
expect(html).toContain('rel="noopener noreferrer"');
|
||||
expect(html).toContain("text-primary");
|
||||
expect(render("./final report.md")).toContain(`href="${reportURL}"`);
|
||||
expect(render("././outputs/notes.md")).toContain(`href="${notesURL}"`);
|
||||
});
|
||||
|
||||
it("keeps ?query and #fragment suffixes on the rewritten URL", () => {
|
||||
expect(render("notes.md?v=2#part-1")).toContain(
|
||||
`href="${notesURL}?v=2#part-1"`,
|
||||
);
|
||||
});
|
||||
|
||||
it("renders ambiguous or unmatched links as text, never a relative href", () => {
|
||||
for (const href of ["shared.md", "missing.md", "./missing.md"]) {
|
||||
const html = render(href);
|
||||
expect(html).not.toContain("<a");
|
||||
expect(html).not.toContain("href=");
|
||||
expect(html).toContain("<span");
|
||||
expect(html).toContain("doc");
|
||||
}
|
||||
// The artifact list is empty until the thread state loads.
|
||||
expect(render("notes.md", [])).not.toContain("href=");
|
||||
});
|
||||
|
||||
it("renders traversal paths inert and preserves non-artifact links", () => {
|
||||
for (const href of ["../notes.md", "..%2fnotes.md"]) {
|
||||
const html = render(href);
|
||||
expect(html).not.toContain("<a");
|
||||
expect(html).not.toContain("href=");
|
||||
expect(html).not.toContain("/api/threads/");
|
||||
}
|
||||
const external = render("https://example.com/notes.md");
|
||||
expect(external).toContain('href="https://example.com/notes.md"');
|
||||
expect(external).toContain('target="_blank"');
|
||||
expect(render("#notes.md")).toContain('href="#notes.md"');
|
||||
expect(render("mailto:a@example.com")).toContain(
|
||||
'href="mailto:a@example.com"',
|
||||
);
|
||||
expect(render("/workspace/chats/new")).toContain(
|
||||
'href="/workspace/chats/new"',
|
||||
);
|
||||
});
|
||||
|
||||
it("styles /mnt/ artifact links like other links", () => {
|
||||
const html = render("/mnt/user-data/outputs/chart.png?v=2#detail");
|
||||
expect(html).toContain(
|
||||
'href="/api/threads/thread-1/artifacts/mnt/user-data/outputs/chart.png?v=2#detail"',
|
||||
);
|
||||
expect(html).toContain('target="_blank"');
|
||||
expect(html).toContain("text-primary");
|
||||
});
|
||||
|
||||
it("leaves relative links alone outside the message list", () => {
|
||||
expect(render("missing.md", null)).toContain('href="missing.md"');
|
||||
const GenericLink = createMarkdownLinkComponent();
|
||||
expect(
|
||||
renderToStaticMarkup(
|
||||
createElement(
|
||||
MessageArtifactPathsContext.Provider,
|
||||
{ value: artifacts },
|
||||
createElement(GenericLink, { href: "notes.md" }, "doc"),
|
||||
),
|
||||
),
|
||||
).toContain('href="notes.md"');
|
||||
});
|
||||
});
|
||||
|
||||
@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, rs } from "@rstest/core";
|
||||
import { createElement, type ComponentProps } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { MessageArtifactPathsContext } from "@/components/workspace/messages/markdown-link";
|
||||
import {
|
||||
MessageGroup,
|
||||
scheduleToolLabel,
|
||||
@ -37,6 +38,24 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("MessageGroup", () => {
|
||||
it("resolves relative artifact links in chain-of-thought assistant text", () => {
|
||||
const html = renderGroup(
|
||||
[
|
||||
{
|
||||
id: "ai-1",
|
||||
type: "ai",
|
||||
content: "Inspect the [report](report.md).",
|
||||
} as Message,
|
||||
],
|
||||
{ isLoading: true, threadId: "thread-1" },
|
||||
["/mnt/user-data/outputs/report.md"],
|
||||
);
|
||||
|
||||
expect(html).toContain(
|
||||
'href="/api/threads/thread-1/artifacts/mnt/user-data/outputs/report.md"',
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["array", "webz"])(
|
||||
"retains valid search links when %s results contain malformed entries",
|
||||
(shape) => {
|
||||
@ -879,7 +898,9 @@ function expectRenderedInOrder(html: string, needles: string[]) {
|
||||
function renderGroup(
|
||||
messages: Message[],
|
||||
props: Omit<ComponentProps<typeof MessageGroup>, "messages"> = {},
|
||||
artifactPaths: readonly string[] | null = null,
|
||||
) {
|
||||
const group = createElement(MessageGroup, { ...props, messages });
|
||||
return renderToStaticMarkup(
|
||||
createElement(
|
||||
I18nContext.Provider,
|
||||
@ -890,7 +911,13 @@ function renderGroup(
|
||||
t: enUS,
|
||||
},
|
||||
},
|
||||
createElement(MessageGroup, { ...props, messages }),
|
||||
artifactPaths === null
|
||||
? group
|
||||
: createElement(
|
||||
MessageArtifactPathsContext.Provider,
|
||||
{ value: artifactPaths },
|
||||
group,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -374,6 +374,84 @@ describe("artifact URL helpers", () => {
|
||||
).toBe("https://example.com/image.png");
|
||||
});
|
||||
|
||||
test("resolveRelativeArtifactURL returns null when it cannot resolve", async () => {
|
||||
const { isRelativeArtifactReference, resolveRelativeArtifactURL } =
|
||||
await loadFreshArtifactUtils();
|
||||
const artifacts = [
|
||||
"/mnt/user-data/outputs/report.md",
|
||||
"/mnt/user-data/outputs/a/dup.md",
|
||||
"/mnt/user-data/outputs/b/dup.md",
|
||||
];
|
||||
|
||||
expect(
|
||||
resolveRelativeArtifactURL("./report.md#s1", "thread-1", artifacts),
|
||||
).toBe(
|
||||
"/api/threads/thread-1/artifacts/mnt/user-data/outputs/report.md#s1",
|
||||
);
|
||||
expect(resolveRelativeArtifactURL("dup.md", "thread-1", artifacts)).toBe(
|
||||
null,
|
||||
);
|
||||
expect(resolveRelativeArtifactURL("none.md", "thread-1", artifacts)).toBe(
|
||||
null,
|
||||
);
|
||||
expect(
|
||||
resolveRelativeArtifactURL("../report.md", "thread-1", artifacts),
|
||||
).toBe(null);
|
||||
|
||||
expect(isRelativeArtifactReference("report.md")).toBe(true);
|
||||
expect(isRelativeArtifactReference("./a/b.md?x=1")).toBe(true);
|
||||
for (const href of [
|
||||
"../report.md",
|
||||
"/workspace/chats/x",
|
||||
"/mnt/user-data/outputs/report.md",
|
||||
"https://example.com/report.md",
|
||||
"mailto:a@example.com",
|
||||
"#section",
|
||||
"?q=1",
|
||||
"",
|
||||
]) {
|
||||
expect(isRelativeArtifactReference(href)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test("collectThreadArtifactPaths adds present_files paths missing from state", async () => {
|
||||
const { collectThreadArtifactPaths } = await loadFreshArtifactUtils();
|
||||
const state = ["/mnt/user-data/outputs/a.md"];
|
||||
const messages = [
|
||||
{ type: "human", id: "h", content: "go" },
|
||||
{
|
||||
type: "ai",
|
||||
id: "a1",
|
||||
content: "",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "p1",
|
||||
name: "present_files",
|
||||
args: {
|
||||
filepaths: [
|
||||
"/mnt/user-data/outputs/a.md",
|
||||
"/mnt/user-data/outputs/b.zip",
|
||||
"outputs/relative-is-ignored.md",
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
] as Parameters<typeof collectThreadArtifactPaths>[1];
|
||||
|
||||
// Reopened thread: the /history head carries no artifacts.
|
||||
expect(collectThreadArtifactPaths([], messages)).toEqual([
|
||||
"/mnt/user-data/outputs/a.md",
|
||||
"/mnt/user-data/outputs/b.zip",
|
||||
]);
|
||||
expect(collectThreadArtifactPaths(state, messages)).toEqual([
|
||||
"/mnt/user-data/outputs/a.md",
|
||||
"/mnt/user-data/outputs/b.zip",
|
||||
]);
|
||||
// Without present_files the state array is returned as-is.
|
||||
expect(collectThreadArtifactPaths(state, [messages[0]!])).toBe(state);
|
||||
});
|
||||
|
||||
test("builds encoded write-file URLs without undefined query parameters", async () => {
|
||||
const { buildWriteFileArtifactURL } = await loadFreshArtifactUtils();
|
||||
const filepath = "/mnt/user-data/outputs/a b#c?%20.md";
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user