This commit is contained in:
fengxiangdebaozi committed 2026-06-03 18:54:26 +08:00
commit 8f6e08eb2f
102 files changed
+6816 -6016

No files matched your search

@@ -16,7 +16,7 @@ export type ArtifactProps = HTMLAttributes<HTMLDivElement>;
export const Artifact = ({ className, ...props }: ArtifactProps) => (
<div
className={cn(
"flex flex-col overflow-hidden rounded-[1.35rem] border border-[#ece7dd] bg-white shadow-[0_24px_60px_-28px_rgba(15,23,42,0.16)] ring-1 ring-black/3",
"flex flex-col overflow-hidden rounded-[1.35rem] border border-border bg-background shadow-[0_24px_60px_-28px_rgba(15,23,42,0.16)] ring-1 ring-black/3",
className,
)}
{...props}
@@ -31,7 +31,7 @@ export const ArtifactHeader = ({
}: ArtifactHeaderProps) => (
<div
className={cn(
"flex items-center justify-between border-b border-[#efe9de] bg-white px-4 py-3",
"flex items-center justify-between border-b border-border bg-background px-4 py-3",
className,
)}
{...props}
@@ -147,7 +147,7 @@ export const ArtifactContent = forwardRef<HTMLDivElement, ArtifactContentProps>(
<div
ref={ref}
className={cn(
"min-h-0 flex-1 overflow-auto bg-white p-4",
"min-h-0 flex-1 overflow-auto bg-background p-4",
className,
)}
{...props}
@@ -2,6 +2,7 @@
import {
BotIcon,
CopyIcon,
GlobeIcon,
InfoIcon,
LockIcon,
@@ -43,12 +44,14 @@ import { TagChipList } from "@/components/workspace/tags/tag-chip";
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
import {
useAgent,
useCreateAgent,
useDeleteAgent,
useSetAgentFeatured,
useSetAgentPrivate,
useToggleAgentFavorite,
useUpdateAgent,
} from "@/core/agents";
import { getAgent } from "@/core/agents/api";
import type { Agent } from "@/core/agents";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
@@ -65,6 +68,7 @@ export function AgentCard({ agent }: AgentCardProps) {
const router = useRouter();
const deleteAgent = useDeleteAgent();
const updateAgent = useUpdateAgent();
const createAgent = useCreateAgent();
const setFeatured = useSetAgentFeatured();
const setPrivate = useSetAgentPrivate();
const toggleFavorite = useToggleAgentFavorite();
@@ -153,6 +157,41 @@ export function AgentCard({ agent }: AgentCardProps) {
}
}
async function handleDuplicate() {
if (createAgent.isPending) {
return;
}
try {
// SOUL.md is omitted from the list payload (perf), so fetch the detail
// row to carry the source agent's personality into the copy. Fall back
// to whatever the list happened to include if the detail fetch fails.
let soul = agent.soul ?? "";
try {
const detail = await getAgent(agent.id);
soul = detail.soul ?? soul;
} catch {
// keep the list fallback
}
const created = await createAgent.mutateAsync({
name: `${agent.name} 副本`,
description: agent.description ?? "",
soul,
skills: agent.skills ?? [],
model: agent.model ?? null,
tool_groups: agent.tool_groups ?? null,
// A copy starts as a private, unpublished draft the owner can refine
// before sharing — never inherit the source's published/private state.
published: false,
is_private: false,
});
toast.success(
`已复制为「${desensitize(created.name)}」,可在「我的」中编辑`,
);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleSaveEdit() {
const trimmedName = editName.trim();
if (!trimmedName) {
@@ -471,6 +510,16 @@ export function AgentCard({ agent }: AgentCardProps) {
<ShieldIcon className="h-3.5 w-3.5" />
</Button>
)}
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => void handleDuplicate()}
disabled={createAgent.isPending}
title="复制为新智能体"
>
<CopyIcon className="h-3.5 w-3.5" />
</Button>
{canEdit && (
<Button
size="icon"
@@ -535,7 +584,7 @@ export function AgentCard({ agent }: AgentCardProps) {
已选 {selectedSkills.length} 个
</span>
</div>
<div className="rounded-md border p-2">
<div className="max-h-[240px] overflow-y-auto rounded-md border p-2">
{isSkillsLoading ? (
<p className="text-muted-foreground text-sm">技能加载中...</p>
) : enabledSkills.length === 0 ? (
@@ -543,13 +592,13 @@ export function AgentCard({ agent }: AgentCardProps) {
当前没有可用技能
</p>
) : (
<div className="flex flex-wrap gap-2">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{enabledSkills.map((skill) => {
const checked = selectedSkills.includes(skill.name);
return (
<div
key={skill.name}
className="inline-flex max-w-full items-center"
className="flex w-full min-w-0 items-center"
>
<Button
type="button"
@@ -557,7 +606,7 @@ export function AgentCard({ agent }: AgentCardProps) {
variant={checked ? "default" : "outline"}
onClick={() => toggleSkill(skill.name)}
title={desensitize(skill.description ?? "")}
className="h-8 max-w-[200px] rounded-r-none"
className="h-8 min-w-0 flex-1 rounded-r-none"
>
<span className="truncate">{desensitize(skill.name)}</span>
</Button>
@@ -16,7 +16,11 @@ import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
import { useSkills } from "@/core/skills/hooks";
import { normalizeDisplaySources } from "@/core/skill-display/display-adapter";
import type { SkillDisplayConfig } from "@/core/skills/type";
import {
displayConfigFromResultSet,
normalizeDisplaySources,
} from "@/core/skill-display/display-adapter";
import {
buildSkillDisplayMap,
collectRuntimeDisplaySources,
@@ -63,9 +67,14 @@ function logReferenceDebug(
});
}
const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
const ChatBox: React.FC<{
children: React.ReactNode;
threadId: string;
disableArtifacts?: boolean;
}> = ({
children,
threadId,
disableArtifacts = false,
}) => {
const { thread } = useThread();
const pathname = usePathname();
@@ -271,12 +280,16 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
}
// Update artifacts from the current thread
const nextArtifacts = thread.values.artifacts ?? [];
const nextArtifacts = disableArtifacts ? [] : (thread.values.artifacts ?? []);
const hadArtifacts = artifactsCountRef.current > 0;
const hasNewArtifact = nextArtifacts.length > artifactsCountRef.current;
artifactsCountRef.current = nextArtifacts.length;
setArtifacts(nextArtifacts);
if ((hasNewArtifact || (!hadArtifacts && nextArtifacts.length > 0)) && !selectedArtifact) {
if (
!disableArtifacts &&
(hasNewArtifact || (!hadArtifacts && nextArtifacts.length > 0)) &&
!selectedArtifact
) {
selectArtifact(nextArtifacts[nextArtifacts.length - 1]!);
setArtifactsOpen(true);
setSideTab("document");
@@ -291,6 +304,7 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
// }
if (
!disableArtifacts &&
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" &&
autoSelectFirstArtifact
) {
@@ -307,14 +321,18 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
selectedArtifact,
setArtifacts,
thread.values.artifacts,
disableArtifacts,
]);
const artifactPanelOpen = useMemo(() => {
if (disableArtifacts) {
return false;
}
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true") {
return artifactsOpen && artifacts?.length > 0;
}
return artifactsOpen;
}, [artifactsOpen, artifacts]);
}, [artifactsOpen, artifacts, disableArtifacts]);
const sidePanelOpen = artifactPanelOpen || (hasCitationReferences && referencesOpen);
const showTabs = !!selectedArtifact && hasCitationReferences;
@@ -343,6 +361,10 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
}
}, [sidePanelOpen]);
if (disableArtifacts) {
return <>{children}</>;
}
return (
<ResizablePanelGroup
id={`${resizableIdBase}-panels`}
@@ -362,14 +384,14 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({
/>
<ResizablePanel
className={cn(
"bg-[#fffdfa] transition-all duration-300 ease-in-out",
"bg-background transition-all duration-300 ease-in-out",
!sidePanelOpen && "opacity-0",
)}
id="artifacts"
>
<div
className={cn(
"h-full bg-[#fffdfa] p-4 transition-transform duration-300 ease-in-out",
"h-full bg-background p-4 transition-transform duration-300 ease-in-out",
sidePanelOpen ? "translate-x-0" : "translate-x-full",
)}
>
@@ -603,9 +625,10 @@ function adaptBackendSourceWithSkillDisplay(
if (!match) {
return null;
}
const display = findDisplayConfigForBackendSource(source, match.display);
const normalized = normalizeDisplaySources(
buildPayloadAtPath(match.display.resultPath || "results", [rawItem]),
match.display,
buildPayloadAtPath(display.resultPath || "results", [rawItem]),
display,
)[0];
if (!normalized) {
return null;
@@ -614,13 +637,27 @@ function adaptBackendSourceWithSkillDisplay(
...source,
...normalized,
skillName: match.skillName,
mode: match.display.mode,
tableColumns: match.display.tableColumns,
mode: display.mode,
tableColumns: display.tableColumns,
index: source.index,
raw: source.raw,
};
}
function findDisplayConfigForBackendSource(
source: BackendReferenceSource,
display: SkillDisplayConfig,
) {
const resultSetKey = source.resultSetKey;
if (resultSetKey) {
const resultSet = display.resultSets?.find((item) => item.key === resultSetKey);
if (resultSet) {
return displayConfigFromResultSet(display, resultSet);
}
}
return { ...display, resultSets: [] };
}
function findBackendSourceDisplay(
source: BackendReferenceSource,
skillDisplayMap: ReturnType<typeof buildSkillDisplayMap>,
@@ -583,7 +583,7 @@ export function InputBox({
// 对话态:常规白卡 + 中性边框 + 轻阴影
isNewThread
? "rounded-3xl bg-transparent border-0 shadow-none ring-0"
: "rounded-3xl bg-background border border-border shadow-sm dark:bg-slate-800 dark:border-slate-700",
: "rounded-3xl bg-background border border-border shadow-sm transition-shadow hover:shadow-md focus-within:border-ring/40 focus-within:shadow-md focus-within:ring-1 focus-within:ring-ring/20 dark:bg-slate-800 dark:border-slate-700",
className,
)}
disabled={disabled}
@@ -258,12 +258,12 @@ function ToolCall({
null);
const displayItems =
displayMatch && result !== undefined
? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
...item,
index: index + 1,
skillName: displayMatch.skillName,
mode: displayMatch.display.mode,
}))
? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
...item,
index: index + 1,
skillName: displayMatch.skillName,
mode: item.displayMode ?? displayMatch.display.mode,
}))
: [];
if (displayItems.length > 0) {
return (
@@ -1,33 +1,41 @@
import type { Message } from "@langchain/langgraph-sdk";
import { ClockIcon, FileIcon, Loader2Icon } from "lucide-react";
import type { Message } from '@langchain/langgraph-sdk';
import { ClockIcon, FileIcon, Loader2Icon, PencilIcon } from 'lucide-react';
import {
memo,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type AnchorHTMLAttributes,
type ImgHTMLAttributes,
} from "react";
import rehypeKatex from "rehype-katex";
} from 'react';
import rehypeKatex from 'rehype-katex';
import { Loader } from "@/components/ai-elements/loader";
import { Loader } from '@/components/ai-elements/loader';
import {
Message as AIElementMessage,
MessageContent as AIElementMessageContent,
MessageResponse as AIElementMessageResponse,
MessageToolbar,
} from "@/components/ai-elements/message";
} 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 { resolveArtifactURL } from "@/core/artifacts/utils";
import { useChatAskTimes } from "@/core/chat-timestamps";
import { getBackendBaseURL } from "@/core/config";
import { useI18n } from "@/core/i18n/hooks";
} 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 { 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 { useI18n } from '@/core/i18n/hooks';
import {
extractContentFromMessage,
extractReasoningContentFromMessage,
@@ -35,22 +43,64 @@ import {
stripArtifactTag,
stripUploadedFilesTag,
type FileInMessage,
} from "@/core/messages/utils";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
} from '@/core/messages/utils';
import { useRehypeSplitWordsIntoSpans } from '@/core/rehype';
import {
applyInlineCitations,
type RuntimeDisplaySource,
} from "@/core/skill-display/runtime";
import { humanMessagePlugins } from "@/core/streamdown";
import { cn } from "@/lib/utils";
} from '@/core/skill-display/runtime';
import { humanMessagePlugins } from '@/core/streamdown';
import { cn } from '@/lib/utils';
import { CitationLink } from "../citations/citation-link";
import { CopyButton } from "../copy-button";
import { SkillDisplayBlock } from "../skill-display-block";
import { ThreadContext } from "./context";
import { DownloadWordButton } from "./download-word-button";
import { MarkdownContent, type MarkdownContentProps } from "./markdown-content";
import { MessageTokenUsage } from "./message-token-usage";
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 { DownloadWordButton } from './download-word-button';
import { MarkdownContent, type MarkdownContentProps } from './markdown-content';
import { MessageTokenUsage } from './message-token-usage';
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.
@@ -64,7 +114,7 @@ function useArtifactResolver(threadId: string): (path: string) => string {
return useCallback(
(path: string) => {
if (publicShareCode) {
const normalized = path.startsWith("/") ? path : `/${path}`;
const normalized = path.startsWith('/') ? path : `/${path}`;
return `${getBackendBaseURL()}/api/public/shares/${encodeURIComponent(
publicShareCode,
)}/files${normalized}`;
@@ -77,50 +127,65 @@ function useArtifactResolver(threadId: string): (path: string) => string {
export function MessageListItem({
className,
fitRowToContent = false,
message,
isLoading,
threadId,
tokenUsageEnabled = false,
displaySources = [],
extraRehypePlugins,
onEditResubmit,
}: {
className?: string;
fitRowToContent?: boolean;
message: Message;
isLoading?: boolean;
threadId: string;
tokenUsageEnabled?: boolean;
displaySources?: RuntimeDisplaySource[];
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
onEditResubmit?: (text: string) => void;
}) {
const isHuman = message.type === "human";
const isHuman = message.type === 'human';
const messageMarkdown =
extractContentFromMessage(message) ??
extractReasoningContentFromMessage(message) ??
"";
'';
return (
<AIElementMessage
className={cn("group/conversation-message relative w-full", className)}
from={isHuman ? "user" : "assistant"}
className={cn(
'group/conversation-message relative',
fitRowToContent ? 'max-w-full w-fit' : 'w-full',
isHuman && fitRowToContent && 'ml-auto',
className,
)}
from={isHuman ? 'user' : 'assistant'}
>
<MessageContent
className={isHuman ? "w-fit" : "w-full"}
className={isHuman ? 'w-fit' : 'w-full'}
message={message}
isLoading={isLoading}
threadId={threadId}
tokenUsageEnabled={tokenUsageEnabled}
displaySources={displaySources}
extraRehypePlugins={extraRehypePlugins}
onEditResubmit={onEditResubmit}
/>
{!isLoading && (
{/*
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(
isHuman ? "-bottom-9 justify-end" : "-bottom-8",
"absolute right-0 left-0 z-20 opacity-0 transition-opacity delay-200 duration-300 group-hover/conversation-message:opacity-100",
'-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} />
{!isHuman && messageMarkdown && (
{messageMarkdown && (
<DownloadWordButton
markdown={messageMarkdown}
fileName="message"
@@ -133,6 +198,128 @@ export function MessageListItem({
);
}
/**
* 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({
text,
pageContext,
filesList,
messageTime,
components,
onEditResubmit,
}: {
text: string;
pageContext?: EmbeddedPageContext | null;
filesList: React.ReactNode;
messageTime: { label: string; full: string } | null;
components: MarkdownContentProps["components"];
onEditResubmit?: (text: string) => void;
}) {
const { t } = useI18n();
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(text);
useEffect(() => {
if (!editing) {
setDraft(text);
}
}, [text, editing]);
const confirmEdit = useCallback(() => {
const value = draft.trim();
if (!value) {
return;
}
onEditResubmit?.(value);
setEditing(false);
}, [draft, onEditResubmit]);
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();
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"
onClick={() => setEditing(false)}
>
{t.common.cancel}
</Button>
<Button size="sm" onClick={confirmEdit} disabled={!draft.trim()}>
{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 && (
<div className="flex items-center gap-0.5 opacity-0 transition-opacity duration-200 group-hover/conversation-message:opacity-100">
<CopyButton clipboardData={text} className="size-6" />
{onEditResubmit && (
<Tooltip content={t.inputBox.editQuestion}>
<Button
size="icon-sm"
type="button"
variant="ghost"
className="size-6"
onClick={() => setEditing(true)}
>
<PencilIcon size={11} />
</Button>
</Tooltip>
)}
</div>
)}
</div>
);
}
/**
* Custom image component that handles artifact URLs
*/
@@ -140,7 +327,7 @@ function MessageImage({
src,
alt,
threadId,
maxWidth = "90%",
maxWidth = '90%',
...props
}: React.ImgHTMLAttributes<HTMLImageElement> & {
threadId: string;
@@ -149,13 +336,13 @@ function MessageImage({
const resolveArtifact = useArtifactResolver(threadId);
if (!src) return null;
const imgClassName = cn("overflow-hidden rounded-lg", `max-w-[${maxWidth}]`);
const imgClassName = cn('overflow-hidden rounded-lg', `max-w-[${maxWidth}]`);
if (typeof src !== "string") {
if (typeof src !== 'string') {
return <img className={imgClassName} src={src} alt={alt} {...props} />;
}
const url = src.startsWith("/mnt/") ? resolveArtifact(src) : src;
const url = src.startsWith('/mnt/') ? resolveArtifact(src) : src;
return (
<a href={url} target="_blank" rel="noopener noreferrer">
@@ -172,6 +359,7 @@ function MessageContent_({
tokenUsageEnabled = false,
displaySources = [],
extraRehypePlugins,
onEditResubmit,
}: {
className?: string;
message: Message;
@@ -180,14 +368,30 @@ function MessageContent_({
tokenUsageEnabled?: boolean;
displaySources?: RuntimeDisplaySource[];
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
onEditResubmit?: (text: string) => void;
}) {
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),
() =>
extraRehypePlugins?.length
? [...baseRehypePlugins, ...extraRehypePlugins]
: baseRehypePlugins,
[baseRehypePlugins, extraRehypePlugins],
);
const askTimes = useChatAskTimes();
const isHuman = message.type === "human";
const isHuman = message.type === 'human';
const resolveArtifact = useArtifactResolver(threadId);
const components = useMemo(
() => ({
@@ -195,13 +399,16 @@ function MessageContent_({
<MessageImage {...props} threadId={threadId} maxWidth="90%" />
),
a: ({ href, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
if (href?.startsWith("#reference-")) {
if (href?.startsWith('#reference-')) {
return <CitationLink href={href} {...props} />;
}
if (typeof props.children === "string" && /^citation:/i.test(props.children)) {
if (
typeof props.children === 'string' &&
/^citation:/i.test(props.children)
) {
return <CitationLink href={href} {...props} />;
}
if (href?.startsWith("/mnt/")) {
if (href?.startsWith('/mnt/')) {
const url = resolveArtifact(href);
return (
<a
@@ -229,7 +436,7 @@ function MessageContent_({
const files = useMemo(() => {
const files = message.additional_kwargs?.files;
if (!Array.isArray(files) || files.length === 0) {
if (rawContent.includes("<uploaded_files>")) {
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);
}
@@ -240,10 +447,19 @@ function MessageContent_({
const contentToDisplay = useMemo(() => {
if (isHuman) {
return rawContent ? stripArtifactTag(stripUploadedFilesTag(rawContent)) : "";
return rawContent
? stripArtifactTag(stripUploadedFilesTag(rawContent))
: '';
}
return applyInlineCitations(rawContent ?? "", displaySources);
return applyInlineCitations(rawContent ?? '', displaySources);
}, [rawContent, isHuman, displaySources]);
const parsedHumanContent = useMemo(
() =>
isHuman
? parseEmbeddedPageContext(contentToDisplay)
: { content: contentToDisplay, pageContext: null },
[contentToDisplay, isHuman],
);
const filesList =
files && files.length > 0 ? (
@@ -251,7 +467,7 @@ function MessageContent_({
) : null;
// Uploading state: mock AI message shown while files upload
if (message.additional_kwargs?.element === "task") {
if (message.additional_kwargs?.element === 'task') {
return (
<AIElementMessageContent className={className}>
<Task defaultOpen={false}>
@@ -284,26 +500,15 @@ function MessageContent_({
}
if (isHuman) {
const messageResponse = contentToDisplay ? (
<AIElementMessageResponse
remarkPlugins={humanMessagePlugins.remarkPlugins}
rehypePlugins={humanMessagePlugins.rehypePlugins}
components={components}
parseIncompleteMarkdown={false}
>
{contentToDisplay}
</AIElementMessageResponse>
) : null;
return (
<div className={cn("ml-auto flex flex-col gap-2", className)}>
{filesList}
{messageResponse && (
<AIElementMessageContent className="w-fit">
{messageResponse}
</AIElementMessageContent>
)}
<MessageTimeStamp time={messageTime} align="right" />
</div>
<HumanMessageBlock
text={parsedHumanContent.content}
pageContext={parsedHumanContent.pageContext}
filesList={filesList}
messageTime={messageTime}
components={components}
onEditResubmit={onEditResubmit}
/>
);
}
@@ -316,7 +521,7 @@ function MessageContent_({
displaySources={displaySources}
isLoading={isLoading}
renderSkillDisplayBlocks={false}
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: "html" }]]}
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: 'html' }]]}
components={components}
/>
</div>
@@ -332,6 +537,27 @@ function MessageContent_({
);
}
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
@@ -340,10 +566,10 @@ function MessageContent_({
function formatAskedTime(
clientTs: unknown,
): { label: string; full: string } | null {
if (typeof clientTs !== "string" || !clientTs) return 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 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())}`;
@@ -356,17 +582,17 @@ function formatAskedTime(
*/
function MessageTimeStamp({
time,
align = "right",
align = 'right',
}: {
time: { label: string; full: string } | null;
align?: "left" | "right";
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",
'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}
>
@@ -380,39 +606,39 @@ function MessageTimeStamp({
* Get file extension and check helpers
*/
const getFileExt = (filename: string) =>
filename.split(".").pop()?.toLowerCase() ?? "";
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",
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"];
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");
return FILE_TYPE_MAP[ext] ?? (ext.toUpperCase() || 'FILE');
}
function isImageFile(filename: string): boolean {
@@ -423,7 +649,7 @@ function isImageFile(filename: string): boolean {
* Format bytes to human-readable size string
*/
function formatBytes(bytes: number): string {
if (bytes === 0) return "—";
if (bytes === 0) return '0 B';
const kb = bytes / 1024;
if (kb < 1024) return `${kb.toFixed(1)} KB`;
return `${(kb / 1024).toFixed(1)} MB`;
@@ -465,8 +691,17 @@ function RichFileCard({
}) {
const { t } = useI18n();
const resolveArtifact = useArtifactResolver(threadId);
const isUploading = file.status === "uploading";
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 (
@@ -495,50 +730,81 @@ function RichFileCard({
);
}
if (!file.path) return null;
if (!fileUrl) return null;
const fileUrl = resolveArtifact(file.path);
const preview = (
<UploadedFilePreviewDialog
filename={file.filename}
fileUrl={fileUrl}
open={previewOpen}
onOpenChange={setPreviewOpen}
/>
);
if (isImage) {
return (
<a
href={fileUrl}
target="_blank"
rel="noopener noreferrer"
className="group border-border/40 relative block overflow-hidden rounded-lg border"
>
<img
src={fileUrl}
alt={file.filename}
className="h-32 w-auto max-w-60 object-cover transition-transform group-hover:scale-105"
/>
</a>
<>
<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 (
<div className="bg-background border-border/40 flex max-w-50 min-w-30 flex-col gap-1 rounded-lg border p-3 shadow-sm">
<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>
</div>
<>
<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}
</>
);
}
@@ -556,8 +822,8 @@ function AssistantDisplayContent({
displaySources: RuntimeDisplaySource[];
isLoading: boolean;
renderSkillDisplayBlocks: boolean;
rehypePlugins: MarkdownContentProps["rehypePlugins"];
components: MarkdownContentProps["components"];
rehypePlugins: MarkdownContentProps['rehypePlugins'];
components: MarkdownContentProps['components'];
}) {
const { parts, placedKeys } = useMemo(
() =>
@@ -573,14 +839,14 @@ function AssistantDisplayContent({
return [];
}
return groupDisplaySources(displaySources).filter(
(group) => group.mode !== "citation" && !placedKeys.has(group.key),
(group) => group.mode !== 'citation' && !placedKeys.has(group.key),
);
}, [displaySources, placedKeys, renderSkillDisplayBlocks]);
return (
<>
{parts.map((part, index) =>
part.type === "markdown" ? (
part.type === 'markdown' ? (
<MarkdownContent
key={`markdown-${index}`}
content={part.content}
@@ -609,11 +875,11 @@ function AssistantDisplayContent({
}
type DisplayPart =
| { type: "markdown"; content: string }
| { type: 'markdown'; content: string }
| {
type: "display";
type: 'display';
key: string;
mode: RuntimeDisplaySource["mode"];
mode: RuntimeDisplaySource['mode'];
sources: RuntimeDisplaySource[];
};
@@ -630,16 +896,16 @@ function splitSkillDisplayPlaceholders(
const start = match.index ?? 0;
const before = content.slice(cursor, start);
if (before.trim()) {
parts.push({ type: "markdown", content: before });
parts.push({ type: 'markdown', content: before });
}
const attrs = parseDisplayAttrs(match[1] ?? "");
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",
type: 'display',
key: group.key,
mode: group.mode,
sources: group.sources,
@@ -649,26 +915,29 @@ function splitSkillDisplayPlaceholders(
}
const rest = content.slice(cursor);
if (rest.trim() || parts.length === 0) {
parts.push({ type: "markdown", content: rest });
parts.push({ type: 'markdown', content: rest });
}
return { parts, placedKeys };
}
function parseDisplayAttrs(raw: string): { tool?: string; mode?: RuntimeDisplaySource["mode"] } {
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,
mode: attrs.mode as RuntimeDisplaySource['mode'] | undefined,
};
}
function findDisplayGroup(
sources: RuntimeDisplaySource[],
tool?: string,
mode?: RuntimeDisplaySource["mode"],
mode?: RuntimeDisplaySource['mode'],
) {
return groupDisplaySources(sources).find((group) => {
if (mode && group.mode !== mode) {
@@ -689,20 +958,18 @@ function groupDisplaySources(sources: RuntimeDisplaySource[]) {
{
key: string;
skillName: string;
mode: RuntimeDisplaySource["mode"];
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: [],
};
const group = groups.get(key) ?? {
key,
skillName: source.skillName,
mode: source.mode,
sources: [],
};
group.sources.push(source);
groups.set(key, group);
}
@@ -710,5 +977,5 @@ function groupDisplaySources(sources: RuntimeDisplaySource[]) {
}
function normalizeName(name: string) {
return name.toLowerCase().replace(/[^a-z0-9]+/g, "");
return name.toLowerCase().replace(/[^a-z0-9]+/g, '');
}
@@ -52,24 +52,30 @@ export const MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM = 80;
export function MessageList({
className,
contentClassName,
fitMessageRowsToContent = false,
threadId,
thread,
paddingBottom = MESSAGE_LIST_DEFAULT_PADDING_BOTTOM,
tokenUsageEnabled = false,
extraRehypePlugins,
onClarificationSubmit,
onEditResubmit,
onSetupAgentApprove,
onSetupAgentReject,
onSetupWritingApprove,
onSetupWritingReject,
}: {
className?: string;
contentClassName?: string;
fitMessageRowsToContent?: boolean;
threadId: string;
thread: BaseStream<AgentThreadState>;
paddingBottom?: number;
tokenUsageEnabled?: boolean;
extraRehypePlugins?: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
onClarificationSubmit?: (answer: string) => void | Promise<void>;
onEditResubmit?: (text: string) => void;
onSetupAgentApprove?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
onSetupAgentReject?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
onSetupWritingApprove?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
@@ -222,7 +228,12 @@ export function MessageList({
<Conversation
className={cn("flex size-full flex-col justify-center", className)}
>
<ConversationContent className="mx-auto w-full max-w-(--container-width-lg) gap-8 pt-12">
<ConversationContent
className={cn(
"mx-auto w-full max-w-(--container-width-lg) gap-8 pt-12",
contentClassName,
)}
>
<MessageMetricsProvider messages={messages} isLoading={thread.isLoading}>
{groupMessages(messages, (group) => {
if (group.type === "human" || group.type === "assistant") {
@@ -240,12 +251,14 @@ export function MessageList({
return (
<MessageListItem
key={`${group.id}/${msg.id}`}
fitRowToContent={fitMessageRowsToContent}
message={msg}
isLoading={thread.isLoading}
threadId={threadId}
tokenUsageEnabled={tokenUsageEnabled}
displaySources={displaySources}
extraRehypePlugins={extraRehypePlugins}
onEditResubmit={onEditResubmit}
/>
);
});
@@ -0,0 +1,278 @@
"use client";
import { DownloadIcon, Loader2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { apiFetch } from "@/core/api/fetch-client";
import { useI18n } from "@/core/i18n/hooks";
import { reasoningPlugins } from "@/core/streamdown";
const markdownClassName =
"!h-auto !w-full max-w-none min-w-0 text-sm leading-7 " +
"[&_h1]:mt-0 [&_h1]:mb-3 [&_h1]:text-xl [&_h1]:font-semibold " +
"[&_h2]:mt-6 [&_h2]:mb-2 [&_h2]:border-b [&_h2]:pb-2 [&_h2]:text-lg [&_h2]:font-semibold " +
"[&_h3]:mt-5 [&_h3]:mb-2 [&_h3]:text-base [&_h3]:font-semibold " +
"[&_p]:my-3 [&_ul]:my-3 [&_ol]:my-3 [&_li]:my-1.5 " +
"[&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-5 [&_ol]:pl-5 " +
"[&_table]:my-4 [&_table]:w-full [&_table]:border-collapse " +
"[&_th]:border [&_th]:px-2 [&_th]:py-1 [&_td]:border [&_td]:px-2 [&_td]:py-1 " +
"[&_blockquote]:my-4 [&_blockquote]:border-l-4 [&_blockquote]:pl-4 [&_blockquote]:text-muted-foreground " +
"[&_code]:rounded [&_code]:border [&_code]:bg-muted [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-xs " +
"[&_pre]:my-4 [&_pre]:max-w-full [&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:border [&_pre]:bg-muted [&_pre]:p-4 " +
"[&_pre_code]:border-0 [&_pre_code]:bg-transparent [&_pre_code]:p-0";
const IMAGE_EXT = ["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp"];
const TEXT_EXT = [
"txt", "md", "markdown", "csv", "tsv", "json", "log", "yaml", "yml",
"xml", "html", "htm", "css", "js", "ts", "tsx", "jsx", "py", "java",
"go", "rs", "c", "cpp", "h", "hpp", "sh", "sql", "ini", "toml", "env",
];
// Office documents are converted to a sibling Markdown file server-side
// (uploads.auto_convert_documents). We preview that .md instead of the binary.
const OFFICE_EXT = ["xlsx", "xls", "docx", "doc", "pptx", "ppt"];
function extOf(filename: string): string {
const i = filename.lastIndexOf(".");
return i >= 0 ? filename.slice(i + 1).toLowerCase() : "";
}
type PreviewKind = "image" | "text" | "office" | "pdf" | "binary";
function kindOf(filename: string): PreviewKind {
const ext = extOf(filename);
if (IMAGE_EXT.includes(ext)) return "image";
if (ext === "pdf") return "pdf";
if (OFFICE_EXT.includes(ext)) return "office";
if (TEXT_EXT.includes(ext)) return "text";
return "binary";
}
function withReplacedExtension(url: string): string {
// Replace the path extension with .md, keeping any query string intact.
const [base, query] = url.split("?");
const i = base.lastIndexOf(".");
const swapped = (i >= 0 ? base.slice(0, i) : base) + ".md";
return query ? `${swapped}?${query}` : swapped;
}
/**
* Fetch an auth-gated artifact (the artifact route requires an
* `Authorization: Bearer` header, which a plain <img>/<iframe> cannot send —
* hence the 401s) and expose it as an object URL for inline display.
*/
export function useAuthedObjectUrl(url: string | null, enabled: boolean) {
const [objectUrl, setObjectUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!enabled || !url) {
setObjectUrl(null);
return;
}
let cancelled = false;
let created: string | null = null;
setLoading(true);
setError(null);
apiFetch(url)
.then(async (res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.blob();
})
.then((blob) => {
if (cancelled) return;
created = URL.createObjectURL(blob);
setObjectUrl(created);
})
.catch((err: unknown) => {
if (!cancelled)
setError(err instanceof Error ? err.message : String(err));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
if (created) URL.revokeObjectURL(created);
};
}, [url, enabled]);
return { objectUrl, loading, error };
}
/**
* Preview dialog for a file attached to a chat message. All fetches go through
* `apiFetch` so the `Authorization` header is attached (artifacts are auth-gated).
*
* - image → fetched as blob, rendered inline
* - text → fetched and shown (markdown rendered for .md, otherwise <pre>)
* - office → fetches the server-converted sibling `<name>.md` and renders it
* - pdf → fetched as blob, embedded via <iframe>
* - else → download link
*/
export function UploadedFilePreviewDialog({
filename,
fileUrl,
open,
onOpenChange,
}: {
filename: string;
/** Absolute artifact URL for the original uploaded file. */
fileUrl: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { t } = useI18n();
const kind = useMemo(() => kindOf(filename), [filename]);
const ext = useMemo(() => extOf(filename), [filename]);
// image / pdf → fetch as authed blob and display via object URL.
const blobEnabled = open && (kind === "image" || kind === "pdf");
const { objectUrl, loading: blobLoading, error: blobError } =
useAuthedObjectUrl(fileUrl, blobEnabled);
// text / office → fetch text (office reads the converted sibling .md).
const textUrl = useMemo(
() => (kind === "office" ? withReplacedExtension(fileUrl) : fileUrl),
[kind, fileUrl],
);
const [text, setText] = useState<string | null>(null);
const [textLoading, setTextLoading] = useState(false);
const [textError, setTextError] = useState<string | null>(null);
useEffect(() => {
if (!open || (kind !== "text" && kind !== "office")) return;
let cancelled = false;
setTextLoading(true);
setTextError(null);
setText(null);
apiFetch(textUrl)
.then(async (res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.text();
})
.then((body) => {
if (!cancelled) setText(body);
})
.catch((err: unknown) => {
if (!cancelled)
setTextError(err instanceof Error ? err.message : String(err));
})
.finally(() => {
if (!cancelled) setTextLoading(false);
});
return () => {
cancelled = true;
};
}, [open, kind, textUrl]);
const renderAsMarkdown =
kind === "office" || ext === "md" || ext === "markdown";
const loadingView = (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-12 text-sm">
<Loader2 className="size-4 animate-spin" />
{t.common.loading}
</div>
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="flex flex-col gap-0 overflow-hidden p-0"
style={{
width: "min(56rem, calc(100vw - 2rem))",
maxWidth: "none",
height: "min(720px, calc(100dvh - 2rem))",
}}
>
<DialogHeader className="shrink-0 border-b px-5 py-3 pr-12">
<DialogTitle className="truncate text-sm">{filename}</DialogTitle>
<DialogDescription className="mt-0.5 truncate text-xs">
<a
href={fileUrl}
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground inline-flex items-center gap-1 underline-offset-2 hover:underline"
>
<DownloadIcon className="size-3" />
{t.common.download}
</a>
</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-auto px-5 py-4">
{kind === "image" ? (
blobLoading ? (
loadingView
) : blobError || !objectUrl ? (
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
) : (
<div className="flex items-center justify-center">
<img
src={objectUrl}
alt={filename}
className="max-h-full max-w-full rounded border"
/>
</div>
)
) : kind === "pdf" ? (
blobLoading ? (
loadingView
) : blobError || !objectUrl ? (
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
) : (
<iframe
src={objectUrl}
title={filename}
className="h-full min-h-[500px] w-full rounded border"
/>
)
) : kind === "text" || kind === "office" ? (
textLoading ? (
loadingView
) : textError ? (
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
) : renderAsMarkdown ? (
<MarkdownContent
className={markdownClassName}
content={text ?? ""}
isLoading={false}
remarkPlugins={reasoningPlugins.remarkPlugins}
rehypePlugins={reasoningPlugins.rehypePlugins}
/>
) : (
<pre className="text-foreground/80 bg-muted/40 max-w-full overflow-x-auto rounded-lg border p-4 font-mono text-xs leading-relaxed whitespace-pre-wrap">
{text ?? ""}
</pre>
)
) : (
<BinaryFallback fileUrl={fileUrl} label={t.common.download} />
)}
</div>
</DialogContent>
</Dialog>
);
}
function BinaryFallback({ fileUrl, label }: { fileUrl: string; label: string }) {
return (
<div className="text-muted-foreground flex flex-col items-center gap-3 rounded-lg border border-dashed px-4 py-12 text-center text-sm">
<Button asChild size="sm" variant="outline">
<a href={fileUrl} target="_blank" rel="noopener noreferrer" download>
<DownloadIcon className="size-3.5" />
{label}
</a>
</Button>
</div>
);
}
@@ -7,6 +7,7 @@ import {
ChevronLeftIcon,
ChevronRightIcon,
ClockIcon,
CopyIcon,
FileArchive,
GlobeIcon,
InfoIcon,
@@ -50,6 +51,7 @@ import {
ItemContent,
ItemDescription,
} from "@/components/ui/item";
import { Input } from "@/components/ui/input";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
import { TagChipList } from "@/components/workspace/tags/tag-chip";
@@ -61,6 +63,7 @@ import { useI18n } from "@/core/i18n/hooks";
import type { ValidateSkillUploadResponse } from "@/core/skills/api";
import {
useDeleteCustomSkill,
useDuplicateSkill,
useInstallSkillUpload,
useReconcileCustomSkills,
useSetSkillPinned,
@@ -390,9 +393,12 @@ export function SkillRow({
const setPublished = useSetSkillPublished();
const setPinned = useSetSkillPinned();
const deleteSkill = useDeleteCustomSkill();
const duplicateSkillMutation = useDuplicateSkill();
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const [tagPickerOpen, setTagPickerOpen] = useState(false);
const [detailOpen, setDetailOpen] = useState(false);
const [duplicateOpen, setDuplicateOpen] = useState(false);
const [duplicateName, setDuplicateName] = useState("");
const isCustom = skill.category === "custom";
// Built-in flag falls back to "no DB owner" so legacy responses without
@@ -428,6 +434,31 @@ export function SkillRow({
}
}
function handleOpenDuplicate() {
// Suggest a "<name>-copy" default that already satisfies the backend's
// lowercase/digit/-/_ skill-name rule. The user can edit it before saving.
setDuplicateName(`${skill.name}-copy`);
setDuplicateOpen(true);
}
async function handleDuplicate() {
const target = duplicateName.trim();
if (!target) {
toast.error("请输入新技能名称");
return;
}
try {
const result = await duplicateSkillMutation.mutateAsync({
sourceName: skill.name,
newName: target,
});
toast.success(`已复制为「${result.skill_name}」`);
setDuplicateOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleTogglePinned() {
const next = !skill.pinned;
try {
@@ -587,6 +618,16 @@ export function SkillRow({
)}
</Button>
)}
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
disabled={duplicateSkillMutation.isPending}
onClick={handleOpenDuplicate}
title="复制为新技能"
>
<CopyIcon className="size-3.5" />
</Button>
{canManage && (
<Button
size="icon"
@@ -600,6 +641,43 @@ export function SkillRow({
)}
</ItemActions>
</Item>
<Dialog open={duplicateOpen} onOpenChange={setDuplicateOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>复制技能</DialogTitle>
<DialogDescription>
将「{desensitize(skill.name)}」复制为一个属于你的新技能。请输入新名称(仅小写字母、数字、- 或 _)。
</DialogDescription>
</DialogHeader>
<Input
value={duplicateName}
onChange={(e) => setDuplicateName(e.currentTarget.value)}
placeholder="new-skill-name"
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void handleDuplicate();
}
}}
/>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDuplicateOpen(false)}
disabled={duplicateSkillMutation.isPending}
>
{t.common.cancel}
</Button>
<Button
onClick={() => void handleDuplicate()}
disabled={duplicateSkillMutation.isPending}
>
{duplicateSkillMutation.isPending ? t.common.loading : "复制"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={confirmDeleteOpen} onOpenChange={setConfirmDeleteOpen}>
<DialogContent>
<DialogHeader>
@@ -17,6 +17,7 @@ import {
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import {
displayConfigFromResultSet,
emptyDisplayConfig,
inferDisplayConfig,
normalizeDisplaySources,
@@ -27,9 +28,29 @@ import {
getSkillDisplaySample,
} from "@/core/skills/api";
import { useSetSkillDisplay } from "@/core/skills/hooks";
import type { Skill, SkillDisplayConfig, SkillDisplayMode, SkillLinkStrategy } from "@/core/skills/type";
import type {
Skill,
SkillDisplayConfig,
SkillDisplayMode,
SkillDisplayResultSetConfig,
SkillLinkStrategy,
} from "@/core/skills/type";
const fieldLabels: Array<[keyof SkillDisplayConfig, string]> = [
type DisplayFieldKey =
| "resultPath"
| "titleField"
| "summaryField"
| "contentField"
| "sourceField"
| "authorField"
| "typeField"
| "timeField"
| "scoreField"
| "urlField"
| "idField"
| "linkTemplate";
const fieldLabels: Array<[DisplayFieldKey, string]> = [
["resultPath", "结果列表路径"],
["titleField", "标题字段"],
["summaryField", "摘要字段"],
@@ -71,14 +92,56 @@ function formatSample(sample: Record<string, unknown> | null | undefined) {
return sample ? JSON.stringify(sample, null, 2) : "";
}
function firstResultSetKey(display: SkillDisplayConfig | null | undefined) {
return display?.resultSets?.[0]?.key ?? null;
}
export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
const queryClient = useQueryClient();
const setDisplay = useSetSkillDisplay();
const lastSkillNameRef = useRef(skill.name);
const [draft, setDraft] = useState<SkillDisplayConfig>(() => skill.display ?? emptyDisplayConfig());
const [activeResultSetKey, setActiveResultSetKey] = useState<string | null>(() =>
firstResultSetKey(skill.display),
);
const [sampleText, setSampleText] = useState(() => formatSample(skill.displaySample));
const [tableColumnsText, setTableColumnsText] = useState(() => JSON.stringify((skill.display ?? emptyDisplayConfig()).tableColumns ?? [], null, 2));
const [tableColumnsText, setTableColumnsText] = useState(() => {
const display = skill.display ?? emptyDisplayConfig();
const resultSet = display.resultSets?.[0];
return JSON.stringify((resultSet ?? display).tableColumns ?? [], null, 2);
});
const [loadingDbConfig, setLoadingDbConfig] = useState(false);
const resultSets = draft.resultSets ?? [];
const activeResultSet =
resultSets.find((resultSet) => resultSet.key === activeResultSetKey) ?? null;
const editingConfig = activeResultSet ?? draft;
const previewConfig = activeResultSet
? displayConfigFromResultSet(draft, activeResultSet)
: draft;
function updateEditingConfig<K extends keyof SkillDisplayResultSetConfig>(
key: K,
value: SkillDisplayResultSetConfig[K],
) {
if (!activeResultSet) {
setDraft(updateConfig(draft, key as keyof SkillDisplayConfig, value as never));
return;
}
setDraft({
...draft,
resultSets: resultSets.map((resultSet) =>
resultSet.key === activeResultSet.key
? { ...resultSet, [key]: value }
: resultSet,
),
});
}
function selectResultSet(key: string) {
const resultSet = resultSets.find((item) => item.key === key) ?? null;
setActiveResultSetKey(resultSet?.key ?? null);
setTableColumnsText(JSON.stringify((resultSet ?? draft).tableColumns ?? [], null, 2));
}
// Echo server state into the form — on mount, on skill switch, AND after
// a successful save (the refetched skill object has a new `display` identity).
@@ -88,7 +151,9 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
useEffect(() => {
const display = skill.display ?? emptyDisplayConfig();
setDraft(display);
setTableColumnsText(JSON.stringify(display.tableColumns ?? [], null, 2));
const resultSet = display.resultSets?.[0];
setActiveResultSetKey(resultSet?.key ?? null);
setTableColumnsText(JSON.stringify((resultSet ?? display).tableColumns ?? [], null, 2));
}, [skill.name, skill.display]);
useEffect(() => {
@@ -101,8 +166,10 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
return;
}
const display = fresh.display ?? emptyDisplayConfig();
const resultSet = display.resultSets?.[0];
setDraft(display);
setTableColumnsText(JSON.stringify(display.tableColumns ?? [], null, 2));
setActiveResultSetKey(resultSet?.key ?? null);
setTableColumnsText(JSON.stringify((resultSet ?? display).tableColumns ?? [], null, 2));
setSampleText(formatSample(fresh.displaySample));
queryClient.setQueryData<Skill | undefined>(["skill", skill.name], fresh);
queryClient.setQueriesData<Skill[]>({ queryKey: ["skills"] }, (current) =>
@@ -186,8 +253,8 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
}, [sampleText]);
const previewItems = useMemo(() => (
samplePayload ? normalizeDisplaySources(samplePayload, draft) : []
), [draft, samplePayload]);
samplePayload ? normalizeDisplaySources(samplePayload, previewConfig) : []
), [previewConfig, samplePayload]);
function generateDraft() {
if (!samplePayload) {
@@ -196,8 +263,14 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
}
const nextDraft = inferDisplayConfig(samplePayload, skill.name);
setDraft(nextDraft);
setTableColumnsText(JSON.stringify(nextDraft.tableColumns ?? [], null, 2));
toast.success("已生成展示适配草稿");
const resultSet = nextDraft.resultSets?.[0];
setActiveResultSetKey(resultSet?.key ?? null);
setTableColumnsText(JSON.stringify((resultSet ?? nextDraft).tableColumns ?? [], null, 2));
toast.success(
resultSet
? `已识别 ${nextDraft.resultSets?.length ?? 0} 个结果集,可分别配置`
: "已生成展示适配草稿",
);
}
async function loadSampleAndGenerate() {
@@ -219,8 +292,14 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
);
const nextDraft = inferDisplayConfig(response.sample, skill.name);
setDraft(nextDraft);
setTableColumnsText(JSON.stringify(nextDraft.tableColumns ?? [], null, 2));
toast.success("已获取样例并生成展示适配");
const resultSet = nextDraft.resultSets?.[0];
setActiveResultSetKey(resultSet?.key ?? null);
setTableColumnsText(JSON.stringify((resultSet ?? nextDraft).tableColumns ?? [], null, 2));
toast.success(
resultSet
? `已获取样例,并识别 ${nextDraft.resultSets?.length ?? 0} 个结果集`
: "已获取样例并生成展示适配",
);
} catch (err) {
generateDraft();
toast.error(err instanceof Error ? err.message : String(err));
@@ -235,8 +314,10 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
sample: samplePayload ?? undefined,
});
const display = saved.display ?? emptyDisplayConfig();
const resultSet = display.resultSets?.find((item) => item.key === activeResultSetKey) ?? display.resultSets?.[0];
setDraft(display);
setTableColumnsText(JSON.stringify(display.tableColumns ?? [], null, 2));
setActiveResultSetKey(resultSet?.key ?? null);
setTableColumnsText(JSON.stringify((resultSet ?? display).tableColumns ?? [], null, 2));
setSampleText(formatSample(saved.displaySample));
toast.success("展示适配已保存");
} catch (err) {
@@ -248,6 +329,7 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
try {
await setDisplay.mutateAsync({ skillName: skill.name, display: null, sample: null });
setDraft(emptyDisplayConfig());
setActiveResultSetKey(null);
setTableColumnsText(JSON.stringify([], null, 2));
toast.success("展示适配已清除");
} catch (err) {
@@ -283,8 +365,8 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
<label className="space-y-1.5">
<span className="text-xs text-muted-foreground">展示方式</span>
<Select
value={draft.mode}
onValueChange={(value) => setDraft(updateConfig(draft, "mode", value as SkillDisplayMode))}
value={editingConfig.mode}
onValueChange={(value) => updateEditingConfig("mode", value as SkillDisplayMode)}
>
<SelectTrigger className="w-full">
<SelectValue />
@@ -300,8 +382,8 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
<label className="space-y-1.5">
<span className="text-xs text-muted-foreground">跳转方式</span>
<Select
value={draft.linkStrategy}
onValueChange={(value) => setDraft(updateConfig(draft, "linkStrategy", value as SkillLinkStrategy))}
value={editingConfig.linkStrategy}
onValueChange={(value) => updateEditingConfig("linkStrategy", value as SkillLinkStrategy)}
>
<SelectTrigger className="w-full">
<SelectValue />
@@ -320,26 +402,58 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
type="number"
min={1}
max={100}
value={draft.maxItems}
onChange={(event) => setDraft(updateConfig(draft, "maxItems", Number(event.currentTarget.value) || 1))}
value={editingConfig.maxItems}
onChange={(event) => updateEditingConfig("maxItems", Number(event.currentTarget.value) || 1)}
/>
</label>
</div>
{resultSets.length > 0 && (
<div className="mt-4 grid gap-3 md:grid-cols-[1fr_auto] md:items-end">
<label className="space-y-1.5">
<span className="text-xs text-muted-foreground">结果集配置</span>
<Select
value={activeResultSet?.key ?? resultSets[0]?.key}
onValueChange={selectResultSet}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{resultSets.map((resultSet) => (
<SelectItem key={resultSet.key} value={resultSet.key}>
{resultSet.label || resultSet.resultPath}
</SelectItem>
))}
</SelectContent>
</Select>
</label>
{activeResultSet && (
<label className="flex items-center justify-end gap-2 pb-2">
<span className="text-xs text-muted-foreground">启用当前结果集</span>
<Switch
checked={activeResultSet.enabled}
onCheckedChange={(checked) => updateEditingConfig("enabled", checked)}
/>
</label>
)}
</div>
)}
<div className="mt-4 grid gap-3 md:grid-cols-2">
{fieldLabels.map(([field, label]) => (
<label key={field} className="space-y-1.5">
<span className="text-xs text-muted-foreground">{label}</span>
<Input
value={(draft[field] as string | number | boolean | null | undefined)?.toString() ?? ""}
onChange={(event) => setDraft(updateConfig(draft, field, event.currentTarget.value as never))}
value={(editingConfig[field] as string | number | boolean | null | undefined)?.toString() ?? ""}
onChange={(event) => updateEditingConfig(field, event.currentTarget.value as never)}
placeholder={field === "linkTemplate" ? "https://xxx.xxx.xxx?recuuid={recUuid}" : undefined}
/>
</label>
))}
</div>
{draft.mode === "table" && (
{editingConfig.mode === "table" && (
<label className="mt-4 block space-y-1.5">
<span className="text-xs text-muted-foreground">表格列配置 JSON</span>
<Textarea
@@ -349,7 +463,7 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
try {
const value = JSON.parse(event.currentTarget.value);
if (Array.isArray(value)) {
setDraft(updateConfig(draft, "tableColumns", value));
updateEditingConfig("tableColumns", value);
}
} catch {
// Keep typing invalid JSON without crashing the panel.
@@ -361,12 +475,12 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) {
</label>
)}
{draft.mode === "custom" && (
{editingConfig.mode === "custom" && (
<label className="mt-4 block space-y-1.5">
<span className="text-xs text-muted-foreground">自定义展示描述</span>
<Textarea
value={draft.customInstruction}
onChange={(event) => setDraft(updateConfig(draft, "customInstruction", event.currentTarget.value))}
value={editingConfig.customInstruction}
onChange={(event) => updateEditingConfig("customInstruction", event.currentTarget.value)}
placeholder="例如:按 publish_time 时间倒序显示为时间线,每条包含标题、摘要、来源和查看原文按钮。"
className="min-h-24"
/>
@@ -1,9 +1,10 @@
import type { BaseStream } from "@langchain/langgraph-sdk";
import { useEffect } from "react";
import { useI18n } from "@/core/i18n/hooks";
import type { AgentThreadState } from "@/core/threads";
import { desensitize } from "@/lib/desensitize";
import { useEffect } from "react";
import { useI18n } from "@/core/i18n/hooks";
import type { AgentThreadState } from "@/core/threads";
import { sanitizeThreadTitleForDisplay } from "@/core/threads/utils";
import { desensitize } from "@/lib/desensitize";
import { useThreadChat } from "./chats";
import { FlipDisplay } from "./flip-display";
@@ -16,16 +17,18 @@ export function ThreadTitle({
threadId: string;
thread: BaseStream<AgentThreadState>;
}) {
const { t } = useI18n();
const { isNewThread } = useThreadChat();
useEffect(() => {
let _title = t.pages.untitled;
if (thread.values?.title) {
_title = thread.values.title;
} else if (isNewThread) {
_title = t.pages.newChat;
}
const { t } = useI18n();
const { isNewThread } = useThreadChat();
const displayTitle = sanitizeThreadTitleForDisplay(thread.values?.title);
useEffect(() => {
let _title = t.pages.untitled;
if (thread.values?.title) {
_title = displayTitle;
} else if (isNewThread) {
_title = t.pages.newChat;
}
if (thread.isThreadLoading) {
document.title = `Loading... - ${t.pages.appName}`;
} else {
@@ -35,17 +38,18 @@ export function ThreadTitle({
isNewThread,
t.pages.newChat,
t.pages.untitled,
t.pages.appName,
thread.isThreadLoading,
thread.values,
]);
t.pages.appName,
thread.isThreadLoading,
thread.values,
displayTitle,
]);
if (!thread.values?.title) {
return null;
}
return (
<FlipDisplay uniqueKey={threadId}>
{desensitize(thread.values.title ?? "Untitled")}
</FlipDisplay>
);
}
return (
<FlipDisplay uniqueKey={threadId}>
{desensitize(displayTitle)}
</FlipDisplay>
);
}
@@ -19,6 +19,9 @@ import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, PenL
import { SidebarCollapseButton } from "./workspace-header";
import { WorkspaceNavStudio } from "./workspace-nav-studio";
import { cn } from "@/lib/utils";
import { env } from "@/env";
const studioEnabled = env.STUDIO_ENABLED?.toLowerCase() === "true";
// 普通导航项:默认中性色,激活态与「新对话」一致——主色浅底 + 主色文字/图标
const NAV_ITEM_CLASS =
@@ -165,7 +168,7 @@ export function WorkspaceNavChatList({
)}
</SidebarMenu>
</SidebarGroup>
{/* {!showCollapseTrigger && <WorkspaceNavStudio />} */}
{!showCollapseTrigger && studioEnabled && <WorkspaceNavStudio />}
</>
);
}
@@ -34,8 +34,6 @@ const NAV_ITEM_CLASS =
"data-[active=true]:bg-primary/15 data-[active=true]:text-primary data-[active=true]:shadow-none " +
"dark:hover:bg-primary/15 dark:data-[active=true]:bg-primary/25 dark:data-[active=true]:text-primary-foreground";
const SIDEBAR_PREVIEW_COUNT = 5;
export function WorkspaceNavStudio() {
const { notebooks, isLoading, isError } = useStudioNotebooks();
const { pathname } = useLocation();
@@ -71,6 +69,7 @@ export function WorkspaceNavStudio() {
return null;
}
const SIDEBAR_PREVIEW_COUNT = 2;
const preview = notebooks.slice(0, SIDEBAR_PREVIEW_COUNT);
return (