feat: improve wiki document and artifact views
This commit is contained in:
1 parent
3785345353
commit
49babb6d25
23 files changed
+1728
-329
No files matched your search
@@ -0,0 +1,226 @@
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
FolderIcon,
|
||||
FolderOpenIcon,
|
||||
RefreshCwIcon,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { artifactPathname, artifactRelativePath } from "@/core/artifacts/listing";
|
||||
import { getFileIcon, getFileName } from "@/core/utils/files";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type DirectoryNode = {
|
||||
name: string;
|
||||
path: string;
|
||||
directories: DirectoryNode[];
|
||||
files: Array<{ artifact: string; name: string; relativePath: string }>;
|
||||
};
|
||||
|
||||
function buildDirectoryTree(artifacts: string[]): DirectoryNode {
|
||||
const root: DirectoryNode = {
|
||||
name: "生成文件",
|
||||
path: "",
|
||||
directories: [],
|
||||
files: [],
|
||||
};
|
||||
|
||||
for (const artifact of artifacts) {
|
||||
const relativePath = artifactRelativePath(artifact);
|
||||
const parts = relativePath.split("/").filter(Boolean);
|
||||
if (parts.length === 0) continue;
|
||||
|
||||
let current = root;
|
||||
for (const directoryName of parts.slice(0, -1)) {
|
||||
let child = current.directories.find((node) => node.name === directoryName);
|
||||
if (!child) {
|
||||
child = {
|
||||
name: directoryName,
|
||||
path: current.path ? `${current.path}/${directoryName}` : directoryName,
|
||||
directories: [],
|
||||
files: [],
|
||||
};
|
||||
current.directories.push(child);
|
||||
}
|
||||
current = child;
|
||||
}
|
||||
|
||||
current.files.push({
|
||||
artifact,
|
||||
name: parts.at(-1) ?? getFileName(artifact),
|
||||
relativePath,
|
||||
});
|
||||
}
|
||||
|
||||
const sort = (node: DirectoryNode) => {
|
||||
node.directories.sort((left, right) =>
|
||||
left.name.localeCompare(right.name, "zh-CN"),
|
||||
);
|
||||
node.files.sort((left, right) =>
|
||||
left.name.localeCompare(right.name, "zh-CN"),
|
||||
);
|
||||
node.directories.forEach(sort);
|
||||
};
|
||||
sort(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
function DirectoryBranch({
|
||||
node,
|
||||
depth,
|
||||
collapsedDirectories,
|
||||
onToggleDirectory,
|
||||
selectedArtifact,
|
||||
onSelect,
|
||||
}: {
|
||||
node: DirectoryNode;
|
||||
depth: number;
|
||||
collapsedDirectories: Set<string>;
|
||||
onToggleDirectory: (path: string) => void;
|
||||
selectedArtifact: string | null;
|
||||
onSelect: (artifact: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{node.directories.map((directory) => {
|
||||
const collapsed = collapsedDirectories.has(directory.path);
|
||||
return (
|
||||
<div key={directory.path}>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:bg-muted/70 hover:text-foreground flex h-8 w-full items-center gap-1.5 rounded-md pr-2 text-left text-xs"
|
||||
style={{ paddingLeft: `${8 + depth * 14}px` }}
|
||||
onClick={() => onToggleDirectory(directory.path)}
|
||||
title={directory.path}
|
||||
>
|
||||
{collapsed ? (
|
||||
<ChevronRightIcon className="size-3.5 shrink-0" />
|
||||
) : (
|
||||
<ChevronDownIcon className="size-3.5 shrink-0" />
|
||||
)}
|
||||
{collapsed ? (
|
||||
<FolderIcon className="size-4 shrink-0 text-amber-500" />
|
||||
) : (
|
||||
<FolderOpenIcon className="size-4 shrink-0 text-amber-500" />
|
||||
)}
|
||||
<span className="truncate">{directory.name}</span>
|
||||
</button>
|
||||
{!collapsed && (
|
||||
<DirectoryBranch
|
||||
node={directory}
|
||||
depth={depth + 1}
|
||||
collapsedDirectories={collapsedDirectories}
|
||||
onToggleDirectory={onToggleDirectory}
|
||||
selectedArtifact={selectedArtifact}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{node.files.map((file) => {
|
||||
const selected =
|
||||
!!selectedArtifact &&
|
||||
artifactPathname(selectedArtifact) === artifactPathname(file.artifact);
|
||||
return (
|
||||
<button
|
||||
key={file.relativePath}
|
||||
type="button"
|
||||
className={cn(
|
||||
"hover:bg-muted/70 flex min-h-8 w-full items-center gap-2 rounded-md py-1 pr-2 text-left text-xs",
|
||||
selected
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-foreground/85",
|
||||
)}
|
||||
style={{ paddingLeft: `${27 + depth * 14}px` }}
|
||||
onClick={() => onSelect(file.artifact)}
|
||||
title={file.relativePath}
|
||||
aria-current={selected ? "page" : undefined}
|
||||
>
|
||||
<span className="shrink-0">{getFileIcon(file.artifact, "size-4")}</span>
|
||||
<span className="truncate">{file.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArtifactDirectory({
|
||||
className,
|
||||
artifacts,
|
||||
selectedArtifact,
|
||||
onSelect,
|
||||
onRefresh,
|
||||
refreshing = false,
|
||||
}: {
|
||||
className?: string;
|
||||
artifacts: string[];
|
||||
selectedArtifact: string | null;
|
||||
onSelect: (artifact: string) => void;
|
||||
onRefresh?: () => void;
|
||||
refreshing?: boolean;
|
||||
}) {
|
||||
const tree = useMemo(() => buildDirectoryTree(artifacts), [artifacts]);
|
||||
const [collapsedDirectories, setCollapsedDirectories] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
|
||||
const toggleDirectory = (path: string) => {
|
||||
setCollapsedDirectories((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(path)) next.delete(path);
|
||||
else next.add(path);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
"bg-muted/20 flex min-h-0 w-48 shrink-0 flex-col border-r xl:w-56",
|
||||
className,
|
||||
)}
|
||||
aria-label="生成文件目录"
|
||||
>
|
||||
<div className="flex h-12 shrink-0 items-center justify-between gap-2 border-b px-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold">生成文件</div>
|
||||
<div className="text-muted-foreground text-[11px]">{artifacts.length} 个文件</div>
|
||||
</div>
|
||||
{onRefresh && (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="size-7 shrink-0"
|
||||
onClick={onRefresh}
|
||||
disabled={refreshing}
|
||||
title="刷新文件目录"
|
||||
aria-label="刷新文件目录"
|
||||
>
|
||||
<RefreshCwIcon className={cn("size-3.5", refreshing && "animate-spin")} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-2">
|
||||
{artifacts.length > 0 ? (
|
||||
<DirectoryBranch
|
||||
node={tree}
|
||||
depth={0}
|
||||
collapsedDirectories={collapsedDirectories}
|
||||
onToggleDirectory={toggleDirectory}
|
||||
selectedArtifact={selectedArtifact}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-muted-foreground px-2 py-8 text-center text-xs leading-5">
|
||||
生成的文件会显示在这里
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -52,11 +52,17 @@ interface ArtifactsProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
|
||||
const [artifacts, setArtifactsState] = useState<string[]>([]);
|
||||
const setArtifacts = useCallback((next: string[]) => {
|
||||
setArtifactsState(Array.isArray(next) ? next : []);
|
||||
}, []);
|
||||
export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
|
||||
const [artifacts, setArtifactsState] = useState<string[]>([]);
|
||||
const setArtifacts = useCallback((next: string[]) => {
|
||||
const normalized = Array.isArray(next) ? next : [];
|
||||
setArtifactsState((current) => {
|
||||
const unchanged =
|
||||
current.length === normalized.length &&
|
||||
current.every((artifact, index) => artifact === normalized[index]);
|
||||
return unchanged ? current : normalized;
|
||||
});
|
||||
}, []);
|
||||
const [selectedArtifact, setSelectedArtifact] = useState<string | null>(null);
|
||||
const [selectedArtifactKey, setSelectedArtifactKey] = useState<string | null>(null);
|
||||
const [autoSelect, setAutoSelect] = useState(true);
|
||||
@@ -99,38 +105,43 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
|
||||
[sidebar],
|
||||
);
|
||||
|
||||
const deselect = useCallback(() => {
|
||||
const deselect = useCallback(() => {
|
||||
setSelectedArtifact(null);
|
||||
setSelectedArtifactKey(null);
|
||||
setAutoSelect(true);
|
||||
setOpenState(false);
|
||||
suppressedAutoOpenKeyRef.current = null;
|
||||
}, []);
|
||||
|
||||
const value: ArtifactsContextType = {
|
||||
artifacts,
|
||||
setArtifacts,
|
||||
|
||||
open,
|
||||
autoOpen,
|
||||
autoSelect,
|
||||
setOpen: (isOpen: boolean) => {
|
||||
if (!isOpen && autoOpen) {
|
||||
setAutoOpen(false);
|
||||
setAutoSelect(false);
|
||||
}
|
||||
|
||||
const setOpen = useCallback(
|
||||
(isOpen: boolean) => {
|
||||
if (!isOpen && autoOpen) {
|
||||
setAutoOpen(false);
|
||||
setAutoSelect(false);
|
||||
}
|
||||
if (!isOpen) {
|
||||
suppressedAutoOpenKeyRef.current = selectedArtifactKey;
|
||||
}
|
||||
setOpenState(isOpen);
|
||||
},
|
||||
[autoOpen, selectedArtifactKey],
|
||||
);
|
||||
|
||||
const value: ArtifactsContextType = {
|
||||
artifacts,
|
||||
setArtifacts,
|
||||
|
||||
open,
|
||||
autoOpen,
|
||||
autoSelect,
|
||||
setOpen,
|
||||
|
||||
selectedArtifact,
|
||||
selectedArtifactKey,
|
||||
select,
|
||||
openStreamedArtifact,
|
||||
deselect,
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<ArtifactsContext.Provider value={value}>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from "./artifact-file-detail";
|
||||
export * from "./artifact-file-list";
|
||||
export * from "./artifact-file-detail";
|
||||
export * from "./artifact-directory";
|
||||
export * from "./artifact-file-list";
|
||||
export * from "./artifact-trigger";
|
||||
export * from "./context";
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { FilesIcon, XIcon } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
@@ -13,6 +14,10 @@ import {
|
||||
} from "@/components/ui/resizable";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { apiFetch } from "@/core/api";
|
||||
import {
|
||||
listThreadArtifacts,
|
||||
mergeArtifactPaths,
|
||||
} from "@/core/artifacts/listing";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
|
||||
import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag";
|
||||
@@ -32,6 +37,7 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
import {
|
||||
ArtifactFileDetail,
|
||||
ArtifactDirectory,
|
||||
ArtifactFileList,
|
||||
useArtifacts,
|
||||
} from "../artifacts";
|
||||
@@ -40,6 +46,7 @@ import { SkillDisplaySources } from "../skill-display-sources";
|
||||
|
||||
const CLOSE_MODE = { chat: 100, artifacts: 0 };
|
||||
const OPEN_MODE = { chat: 60, artifacts: 40 };
|
||||
const DIRECTORY_OPEN_MODE = { chat: 48, artifacts: 52 };
|
||||
const SHOW_REFERENCE_EVENT = "workspace:show-reference";
|
||||
|
||||
type BackendReferenceSource = Parameters<typeof SkillDisplaySources>[0]["sources"][number];
|
||||
@@ -78,7 +85,7 @@ const ChatBox: React.FC<{
|
||||
threadId,
|
||||
disableArtifacts = false,
|
||||
}) => {
|
||||
const { thread } = useThread();
|
||||
const { isMock, thread } = useThread();
|
||||
const pathname = usePathname();
|
||||
const threadIdRef = useRef(threadId);
|
||||
const artifactsCountRef = useRef(0);
|
||||
@@ -92,6 +99,8 @@ const ChatBox: React.FC<{
|
||||
const [pinnedReferenceId, setPinnedReferenceId] = useState<string | null>(null);
|
||||
const [backendReferenceBatches, setBackendReferenceBatches] = useState<BackendReferenceBatch[]>([]);
|
||||
const [settings] = useLocalSettings();
|
||||
const artifactDirectoryModeEnabled =
|
||||
settings.display.artifactDirectoryModeEnabled ?? false;
|
||||
const ragModeEnabled =
|
||||
settings.display.ragModeEnabled || isThreadRagModeLocked(threadId);
|
||||
// Frontend RAG modes write their own hidden source tool messages. They use
|
||||
@@ -110,6 +119,52 @@ const ChatBox: React.FC<{
|
||||
referencesEnabled && !hasFrontendRag;
|
||||
const { skills } = useSkills();
|
||||
|
||||
const canScanArtifactDirectory =
|
||||
artifactDirectoryModeEnabled &&
|
||||
!disableArtifacts &&
|
||||
!isMock &&
|
||||
!!threadId &&
|
||||
threadId !== "new";
|
||||
const artifactListingQuery = useQuery({
|
||||
queryKey: ["artifact-directory", threadId],
|
||||
queryFn: ({ signal }) => listThreadArtifacts(threadId, signal),
|
||||
enabled: canScanArtifactDirectory,
|
||||
refetchInterval: thread.isLoading ? 1_500 : false,
|
||||
retry: false,
|
||||
});
|
||||
const checkpointArtifacts = useMemo(
|
||||
() => (disableArtifacts ? [] : (thread.values.artifacts ?? [])),
|
||||
[disableArtifacts, thread.values.artifacts],
|
||||
);
|
||||
const checkpointArtifactSignature = checkpointArtifacts.join("\n");
|
||||
const availableArtifacts = useMemo(
|
||||
() =>
|
||||
artifactDirectoryModeEnabled
|
||||
? mergeArtifactPaths(
|
||||
checkpointArtifacts,
|
||||
(artifactListingQuery.data?.files ?? []).map((file) => file.path),
|
||||
)
|
||||
: checkpointArtifacts,
|
||||
[
|
||||
artifactDirectoryModeEnabled,
|
||||
artifactListingQuery.data?.files,
|
||||
checkpointArtifacts,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canScanArtifactDirectory || thread.isLoading) return;
|
||||
// Run once after streaming settles as well as when checkpoint artifacts
|
||||
// change, so files written without present_files are not missed by the
|
||||
// last polling tick.
|
||||
void artifactListingQuery.refetch();
|
||||
}, [
|
||||
artifactListingQuery.refetch,
|
||||
canScanArtifactDirectory,
|
||||
checkpointArtifactSignature,
|
||||
thread.isLoading,
|
||||
]);
|
||||
|
||||
const {
|
||||
artifacts,
|
||||
open: artifactsOpen,
|
||||
@@ -302,7 +357,8 @@ const ChatBox: React.FC<{
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (threadIdRef.current !== threadId) {
|
||||
const threadChanged = threadIdRef.current !== threadId;
|
||||
if (threadChanged) {
|
||||
threadIdRef.current = threadId;
|
||||
artifactsCountRef.current = 0;
|
||||
deselect();
|
||||
@@ -316,15 +372,21 @@ const ChatBox: React.FC<{
|
||||
}
|
||||
|
||||
// Update artifacts from the current thread
|
||||
const nextArtifacts = disableArtifacts ? [] : (thread.values.artifacts ?? []);
|
||||
const nextArtifacts = availableArtifacts;
|
||||
const hadArtifacts = artifactsCountRef.current > 0;
|
||||
const hasNewArtifact = nextArtifacts.length > artifactsCountRef.current;
|
||||
artifactsCountRef.current = nextArtifacts.length;
|
||||
setArtifacts(nextArtifacts);
|
||||
const artifactsChanged =
|
||||
artifacts.length !== nextArtifacts.length ||
|
||||
artifacts.some((artifact, index) => artifact !== nextArtifacts[index]);
|
||||
if (artifactsChanged) {
|
||||
setArtifacts(nextArtifacts);
|
||||
}
|
||||
if (
|
||||
!disableArtifacts &&
|
||||
(hasNewArtifact || (!hadArtifacts && nextArtifacts.length > 0)) &&
|
||||
!selectedArtifact
|
||||
nextArtifacts.length > 0 &&
|
||||
((artifactDirectoryModeEnabled && threadChanged) ||
|
||||
((hasNewArtifact || !hadArtifacts) && !selectedArtifact))
|
||||
) {
|
||||
selectArtifact(nextArtifacts[nextArtifacts.length - 1]!);
|
||||
setArtifactsOpen(true);
|
||||
@@ -351,15 +413,41 @@ const ChatBox: React.FC<{
|
||||
}
|
||||
}, [
|
||||
threadId,
|
||||
artifactDirectoryModeEnabled,
|
||||
autoSelectFirstArtifact,
|
||||
deselect,
|
||||
selectArtifact,
|
||||
selectedArtifact,
|
||||
artifacts,
|
||||
setArtifacts,
|
||||
thread.values.artifacts,
|
||||
availableArtifacts,
|
||||
disableArtifacts,
|
||||
]);
|
||||
|
||||
const directoryModeWasEnabledRef = useRef(artifactDirectoryModeEnabled);
|
||||
useEffect(() => {
|
||||
const wasEnabled = directoryModeWasEnabledRef.current;
|
||||
directoryModeWasEnabledRef.current = artifactDirectoryModeEnabled;
|
||||
if (
|
||||
wasEnabled ||
|
||||
!artifactDirectoryModeEnabled ||
|
||||
availableArtifacts.length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (!selectedArtifact) {
|
||||
selectArtifact(availableArtifacts[availableArtifacts.length - 1]!);
|
||||
}
|
||||
setArtifactsOpen(true);
|
||||
setSideTab("document");
|
||||
}, [
|
||||
artifactDirectoryModeEnabled,
|
||||
availableArtifacts,
|
||||
selectArtifact,
|
||||
selectedArtifact,
|
||||
setArtifactsOpen,
|
||||
]);
|
||||
|
||||
const artifactPanelOpen = useMemo(() => {
|
||||
if (disableArtifacts) {
|
||||
return false;
|
||||
@@ -370,7 +458,10 @@ const ChatBox: React.FC<{
|
||||
return artifactsOpen;
|
||||
}, [artifactsOpen, artifacts, disableArtifacts]);
|
||||
const sidePanelOpen = artifactPanelOpen || (hasCitationReferences && referencesOpen);
|
||||
const showTabs = !!selectedArtifact && hasCitationReferences;
|
||||
const showTabs =
|
||||
hasCitationReferences &&
|
||||
(!!selectedArtifact ||
|
||||
(artifactDirectoryModeEnabled && artifacts.length > 0));
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedArtifact) {
|
||||
@@ -390,12 +481,103 @@ const ChatBox: React.FC<{
|
||||
useEffect(() => {
|
||||
if (layoutRef.current) {
|
||||
if (sidePanelOpen) {
|
||||
layoutRef.current.setLayout(OPEN_MODE);
|
||||
layoutRef.current.setLayout(
|
||||
artifactDirectoryModeEnabled && artifactPanelOpen
|
||||
? DIRECTORY_OPEN_MODE
|
||||
: OPEN_MODE,
|
||||
);
|
||||
} else {
|
||||
layoutRef.current.setLayout(CLOSE_MODE);
|
||||
}
|
||||
}
|
||||
}, [sidePanelOpen]);
|
||||
}, [artifactDirectoryModeEnabled, artifactPanelOpen, sidePanelOpen]);
|
||||
|
||||
const artifactDocumentPanel = artifactDirectoryModeEnabled ? (
|
||||
<div className="flex size-full min-w-0 overflow-hidden rounded-lg border">
|
||||
<ArtifactDirectory
|
||||
artifacts={artifacts}
|
||||
selectedArtifact={selectedArtifact}
|
||||
onSelect={(artifact) => {
|
||||
selectArtifact(artifact);
|
||||
setArtifactsOpen(true);
|
||||
}}
|
||||
onRefresh={
|
||||
canScanArtifactDirectory
|
||||
? () => void artifactListingQuery.refetch()
|
||||
: undefined
|
||||
}
|
||||
refreshing={artifactListingQuery.isFetching}
|
||||
/>
|
||||
<div className="relative min-w-0 flex-1 overflow-hidden">
|
||||
{selectedArtifact ? (
|
||||
<ArtifactFileDetail
|
||||
key={selectedArtifactKey ?? selectedArtifact}
|
||||
className="size-full"
|
||||
filepath={selectedArtifact}
|
||||
threadId={threadId}
|
||||
allowVirtualMarkdownEdit
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="absolute top-1 right-1 z-30">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
onClick={() => setArtifactsOpen(false)}
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<ConversationEmptyState
|
||||
icon={<FilesIcon />}
|
||||
title="请选择文件"
|
||||
description="从左侧目录中选择一个文件进行查看"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : selectedArtifact ? (
|
||||
<ArtifactFileDetail
|
||||
key={selectedArtifactKey ?? selectedArtifact}
|
||||
className="size-full"
|
||||
filepath={selectedArtifact}
|
||||
threadId={threadId}
|
||||
allowVirtualMarkdownEdit
|
||||
/>
|
||||
) : (
|
||||
<div className="relative flex size-full justify-center">
|
||||
<div className="absolute top-1 right-1 z-30">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
onClick={() => setArtifactsOpen(false)}
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
{artifacts.length === 0 ? (
|
||||
<ConversationEmptyState
|
||||
icon={<FilesIcon />}
|
||||
title="No artifact selected"
|
||||
description="Select an artifact to view its details"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex size-full max-w-(--container-width-sm) flex-col justify-center p-4 pt-8">
|
||||
<header className="shrink-0">
|
||||
<h2 className="text-lg font-medium">Artifacts</h2>
|
||||
</header>
|
||||
<main className="min-h-0 grow">
|
||||
<ArtifactFileList
|
||||
className="max-w-(--container-width-sm) p-4 pt-12"
|
||||
files={artifacts}
|
||||
threadId={threadId}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (disableArtifacts) {
|
||||
return (
|
||||
@@ -455,13 +637,7 @@ const ChatBox: React.FC<{
|
||||
</Button>
|
||||
</div>
|
||||
<TabsContent value="document" className="min-h-0 flex-1 overflow-hidden p-0">
|
||||
<ArtifactFileDetail
|
||||
key={selectedArtifactKey ?? selectedArtifact}
|
||||
className="size-full"
|
||||
filepath={selectedArtifact}
|
||||
threadId={threadId}
|
||||
allowVirtualMarkdownEdit
|
||||
/>
|
||||
{artifactDocumentPanel}
|
||||
</TabsContent>
|
||||
<TabsContent value="references" className="min-h-0 flex-1 overflow-y-auto p-0">
|
||||
<ReferencePanel
|
||||
@@ -470,14 +646,8 @@ const ChatBox: React.FC<{
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : selectedArtifact ? (
|
||||
<ArtifactFileDetail
|
||||
key={selectedArtifactKey ?? selectedArtifact}
|
||||
className="size-full"
|
||||
filepath={selectedArtifact}
|
||||
threadId={threadId}
|
||||
allowVirtualMarkdownEdit
|
||||
/>
|
||||
) : artifactPanelOpen ? (
|
||||
artifactDocumentPanel
|
||||
) : hasCitationReferences ? (
|
||||
<div className="relative size-full overflow-hidden">
|
||||
<div className="absolute top-1 right-1 z-30">
|
||||
@@ -497,41 +667,7 @@ const ChatBox: React.FC<{
|
||||
sources={referenceSources}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative flex size-full justify-center">
|
||||
<div className="absolute top-1 right-1 z-30">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setArtifactsOpen(false);
|
||||
}}
|
||||
>
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
{thread.values.artifacts?.length === 0 ? (
|
||||
<ConversationEmptyState
|
||||
icon={<FilesIcon />}
|
||||
title="No artifact selected"
|
||||
description="Select an artifact to view its details"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex size-full max-w-(--container-width-sm) flex-col justify-center p-4 pt-8">
|
||||
<header className="shrink-0">
|
||||
<h2 className="text-lg font-medium">Artifacts</h2>
|
||||
</header>
|
||||
<main className="min-h-0 grow">
|
||||
<ArtifactFileList
|
||||
className="max-w-(--container-width-sm) p-4 pt-12"
|
||||
files={thread.values.artifacts ?? []}
|
||||
threadId={threadId}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
|
||||
@@ -4,8 +4,9 @@ import type { ChatStatus } from "ai";
|
||||
import {
|
||||
BrainCircuitIcon,
|
||||
BookMarkedIcon,
|
||||
CheckIcon,
|
||||
FileTextIcon,
|
||||
CheckIcon,
|
||||
FileTextIcon,
|
||||
FolderTreeIcon,
|
||||
GraduationCapIcon,
|
||||
LightbulbIcon,
|
||||
PaperclipIcon,
|
||||
@@ -368,12 +369,20 @@ export function InputBox({
|
||||
[onContextChange, context],
|
||||
);
|
||||
|
||||
const handleWritingModeToggle = useCallback(() => {
|
||||
const handleWritingModeToggle = useCallback(() => {
|
||||
onContextChange?.({
|
||||
...context,
|
||||
writing_mode: !context.writing_mode,
|
||||
});
|
||||
}, [context, onContextChange]);
|
||||
}, [context, onContextChange]);
|
||||
|
||||
const artifactDirectoryModeEnabled =
|
||||
localSettings.display.artifactDirectoryModeEnabled ?? false;
|
||||
const handleArtifactDirectoryModeToggle = useCallback(() => {
|
||||
setLocalSettings("display", {
|
||||
artifactDirectoryModeEnabled: !artifactDirectoryModeEnabled,
|
||||
});
|
||||
}, [artifactDirectoryModeEnabled, setLocalSettings]);
|
||||
|
||||
const memoryInjectionEnabled = context.memory_injection_enabled ?? true;
|
||||
const handleMemoryInjectionToggle = useCallback(() => {
|
||||
@@ -736,7 +745,7 @@ export function InputBox({
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{!frontendRagActive && <Tooltip
|
||||
{!frontendRagActive && <Tooltip
|
||||
content={
|
||||
context.writing_mode
|
||||
? "写作模式已开启:最终输出会强制保存为 Markdown 文件"
|
||||
@@ -760,9 +769,38 @@ export function InputBox({
|
||||
)}
|
||||
<FileTextIcon className="size-3" />
|
||||
<span className="text-xs font-normal">写作</span>
|
||||
</PromptInputButton>
|
||||
</Tooltip>}
|
||||
{!frontendRagActive && <Tooltip
|
||||
</PromptInputButton>
|
||||
</Tooltip>}
|
||||
{!frontendRagActive && <Tooltip
|
||||
content={
|
||||
artifactDirectoryModeEnabled
|
||||
? "文件模式已开启:右侧会显示当前对话生成的全部文件目录"
|
||||
: "开启文件模式:在右侧目录中浏览当前对话生成的全部文件"
|
||||
}
|
||||
>
|
||||
<PromptInputButton
|
||||
type="button"
|
||||
aria-pressed={artifactDirectoryModeEnabled}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"gap-1.5! rounded-full px-3! shadow-none",
|
||||
compact && "rounded-lg px-2.5! py-1! text-[11px] font-medium",
|
||||
artifactDirectoryModeEnabled
|
||||
? "border border-sky-300 bg-sky-50 text-sky-700 hover:bg-sky-100 dark:border-sky-600 dark:bg-sky-950/45 dark:text-sky-200"
|
||||
: compact
|
||||
? "border border-transparent bg-muted/40 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
: "border-0 bg-transparent text-muted-foreground hover:bg-muted/70 hover:text-foreground dark:text-slate-400 dark:hover:bg-slate-700/70 dark:hover:text-slate-100",
|
||||
)}
|
||||
onClick={handleArtifactDirectoryModeToggle}
|
||||
>
|
||||
{artifactDirectoryModeEnabled && (
|
||||
<CheckIcon className="size-3 text-sky-700 dark:text-sky-200" />
|
||||
)}
|
||||
<FolderTreeIcon className="size-3" />
|
||||
<span className="text-xs font-normal">文件</span>
|
||||
</PromptInputButton>
|
||||
</Tooltip>}
|
||||
{!frontendRagActive && <Tooltip
|
||||
content={
|
||||
memoryInjectionEnabled
|
||||
? "记忆注入已开启:对话前会注入历史记忆"
|
||||
|
||||
@@ -262,7 +262,7 @@ function ToolCall({
|
||||
? null
|
||||
: (findDisplayConfigForTool(name, skillDisplayMap, args) ??
|
||||
null);
|
||||
const displayItems =
|
||||
const displayItems =
|
||||
displayMatch && result !== undefined
|
||||
? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({
|
||||
...item,
|
||||
@@ -270,15 +270,21 @@ function ToolCall({
|
||||
skillName: displayMatch.skillName,
|
||||
mode: item.displayMode ?? displayMatch.display.mode,
|
||||
}))
|
||||
: [];
|
||||
: [];
|
||||
const wikiQuery = name === "llmwiki_search" ? resolveSearchQuery(args, result) : "";
|
||||
const wikiSearchLabel = name === "llmwiki_search"
|
||||
? wikiQuery
|
||||
? `Wiki 文档检索 "${wikiQuery}"`
|
||||
: "Wiki 文档检索"
|
||||
: null;
|
||||
if (displayItems.length > 0) {
|
||||
return (
|
||||
<ChainOfThoughtStep
|
||||
key={id}
|
||||
label={
|
||||
displayMatch?.display.mode === "citation" || !displayMatch
|
||||
wikiSearchLabel || (displayMatch?.display.mode === "citation" || !displayMatch
|
||||
? "检索参考来源"
|
||||
: `获取 ${displayItems.length} 条结果`
|
||||
: `获取 ${displayItems.length} 条结果`)
|
||||
}
|
||||
icon={SearchIcon}
|
||||
>
|
||||
@@ -303,7 +309,7 @@ function ToolCall({
|
||||
mode: "citation" as const,
|
||||
}));
|
||||
return (
|
||||
<ChainOfThoughtStep key={id} label="检索参考来源" icon={SearchIcon}>
|
||||
<ChainOfThoughtStep key={id} label={wikiSearchLabel || "检索参考来源"} icon={SearchIcon}>
|
||||
<DisplayResultsList results={defaultItems} />
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ExternalLinkIcon, FileTextIcon } from "lucide-react";
|
||||
import { BookOpenTextIcon, ChevronRightIcon, ExternalLinkIcon, FileTextIcon } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -12,9 +12,11 @@ import type { RuntimeDisplaySource } from "@/core/skill-display/runtime";
|
||||
import { resolveWeKnoraSourceTarget } from "@/core/llmwiki/source-links";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import { useCitationDisplaySettings } from "@/core/system-settings/hooks";
|
||||
import { reasoningPlugins } from "@/core/streamdown";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useState } from "react";
|
||||
import { WeKnoraSourceDrawer } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer";
|
||||
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
||||
|
||||
export function SkillDisplaySources({
|
||||
highlightedReferenceId,
|
||||
@@ -63,8 +65,20 @@ export function SourceCard({
|
||||
const cleanupWords =
|
||||
citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? [];
|
||||
const content = cleanReferenceText(source.content || source.snippet, cleanupWords);
|
||||
const snippet = compactReferenceText(content).slice(0, 280);
|
||||
const paragraphs = segmentReferenceText(content);
|
||||
const isWikiSource = Boolean(previewTarget?.wikiSlug) || source.type === "llmwiki_wiki";
|
||||
const matchedHeading = isWikiSource
|
||||
? String(
|
||||
(source.raw?.matched_heading as string | undefined) ||
|
||||
(source.raw?.matched_sections as Array<{ heading?: string }> | undefined)?.find(
|
||||
(section) => section.heading,
|
||||
)?.heading ||
|
||||
"",
|
||||
).trim()
|
||||
: "";
|
||||
const summary = isWikiSource && typeof source.raw?.summary === "string"
|
||||
? source.raw.summary
|
||||
: "";
|
||||
const snippet = plainTextReferencePreview(summary || content).slice(0, 280);
|
||||
const score =
|
||||
typeof source.score === "number"
|
||||
? source.score <= 1
|
||||
@@ -73,16 +87,22 @@ export function SourceCard({
|
||||
: null;
|
||||
const meta = [
|
||||
source.source,
|
||||
previewTarget?.wikiSlug && source.raw?.matched_sections
|
||||
? `Wiki 章节 ${String((source.raw.matched_sections as Array<{ heading?: string }>)[0]?.heading || "")}`
|
||||
: null,
|
||||
isWikiSource ? "检索对象:Wiki 文档" : null,
|
||||
matchedHeading ? `命中章节:${matchedHeading}` : null,
|
||||
source.author ? `作者 ${source.author}` : null,
|
||||
source.type && source.type !== "result" ? source.type : null,
|
||||
!isWikiSource && source.type && source.type !== "result" ? source.type : null,
|
||||
source.time,
|
||||
score ? `相关度 ${score}` : null,
|
||||
].filter(Boolean);
|
||||
const isCitation = source.mode === "citation";
|
||||
const showReferenceNumber = isCitation;
|
||||
const openSource = () => {
|
||||
if (previewTarget) {
|
||||
setDetailOpen(true);
|
||||
} else {
|
||||
setRelatedOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -97,11 +117,11 @@ export function SourceCard({
|
||||
"border-amber-300 bg-amber-50/80 shadow-[0_0_0_2px_rgba(251,191,36,0.14)]",
|
||||
"cursor-pointer",
|
||||
)}
|
||||
onClick={() => setRelatedOpen(true)}
|
||||
onClick={openSource}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
setRelatedOpen(true);
|
||||
openSource();
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -131,11 +151,16 @@ export function SourceCard({
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{source.url && (
|
||||
{(source.url || isWikiSource) && (
|
||||
previewTarget ? (
|
||||
<span className="text-muted-foreground bg-muted mt-0.5 inline-flex shrink-0 items-center gap-1 rounded-md px-1.5 py-1 text-[11px] font-medium">
|
||||
<FileTextIcon className="size-3.5" />
|
||||
{previewTarget.wikiSlug ? "Wiki 页面" : "知识库文档"}
|
||||
<span className={cn(
|
||||
"mt-0.5 inline-flex shrink-0 items-center gap-1 rounded-md px-1.5 py-1 text-[11px] font-medium",
|
||||
isWikiSource
|
||||
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}>
|
||||
{isWikiSource ? <BookOpenTextIcon className="size-3.5" /> : <FileTextIcon className="size-3.5" />}
|
||||
{isWikiSource ? "Wiki 文档" : "知识库文档"}
|
||||
</span>
|
||||
) : (
|
||||
<ExternalLinkIcon className="text-muted-foreground/60 group-hover:text-primary mt-1 size-3.5 shrink-0 transition-colors" />
|
||||
@@ -143,19 +168,9 @@ export function SourceCard({
|
||||
)}
|
||||
</div>
|
||||
{previewTarget && (
|
||||
<div className="mt-3 flex justify-start border-t pt-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-primary h-7 px-1.5 text-xs"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setDetailOpen(true);
|
||||
}}
|
||||
>
|
||||
<ExternalLinkIcon className="mr-1.5 size-3.5" />
|
||||
{previewTarget.wikiSlug ? "查看 Wiki" : "查看详情"}
|
||||
</Button>
|
||||
<div className="mt-3 flex items-center justify-between border-t pt-2 text-xs text-muted-foreground">
|
||||
<span>{isWikiSource ? "点击卡片查看完整 Wiki" : "点击卡片查看详情"}</span>
|
||||
<ChevronRightIcon className="size-3.5 transition-transform group-hover:translate-x-0.5" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -168,7 +183,7 @@ export function SourceCard({
|
||||
onOpenChange={setDetailOpen}
|
||||
/>
|
||||
)}
|
||||
<Dialog open={relatedOpen} onOpenChange={setRelatedOpen}>
|
||||
{!previewTarget && <Dialog open={relatedOpen} onOpenChange={setRelatedOpen}>
|
||||
<DialogContent className="max-h-[90vh] w-[calc(100vw-2rem)] max-w-3xl overflow-hidden">
|
||||
<DialogHeader>
|
||||
<div className="flex items-start justify-between gap-3 pr-8">
|
||||
@@ -205,13 +220,23 @@ export function SourceCard({
|
||||
)}
|
||||
</div>
|
||||
</DialogHeader>
|
||||
{paragraphs.length > 0 ? (
|
||||
<div className="text-foreground max-h-[55vh] space-y-3 overflow-y-auto text-sm leading-relaxed">
|
||||
{paragraphs.map((paragraph, index) => (
|
||||
<p key={index} className="whitespace-pre-wrap">
|
||||
{paragraph}
|
||||
</p>
|
||||
))}
|
||||
{content ? (
|
||||
<div className="max-h-[62vh] overflow-y-auto rounded-xl border bg-slate-50/70 p-4 dark:bg-slate-950/40 sm:p-6">
|
||||
{isWikiSource && (
|
||||
<div className="mb-4 flex items-center gap-2 border-b pb-3 text-xs font-medium text-emerald-700 dark:text-emerald-300">
|
||||
<BookOpenTextIcon className="size-4" />
|
||||
本次检索命中的 Wiki 文档内容
|
||||
</div>
|
||||
)}
|
||||
<article className="mx-auto max-w-3xl rounded-lg bg-background px-4 py-5 shadow-sm ring-1 ring-border/70 sm:px-7">
|
||||
<MarkdownContent
|
||||
className="max-w-none"
|
||||
content={content}
|
||||
isLoading={false}
|
||||
remarkPlugins={reasoningPlugins.remarkPlugins}
|
||||
rehypePlugins={reasoningPlugins.rehypePlugins}
|
||||
/>
|
||||
</article>
|
||||
</div>
|
||||
) : null}
|
||||
{source.url && !previewTarget && (
|
||||
@@ -220,7 +245,7 @@ export function SourceCard({
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Dialog>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -249,44 +274,18 @@ function compactReferenceText(text: string): string {
|
||||
return text.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function segmentReferenceText(text: string): string[] {
|
||||
const explicit = text
|
||||
.split(/\n{2,}/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
const base = explicit.length > 1 ? explicit : splitLongParagraph(text);
|
||||
return base.slice(0, 80);
|
||||
}
|
||||
|
||||
function splitLongParagraph(text: string): string[] {
|
||||
const normalized = text
|
||||
.split(/\n+/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
if (normalized.length <= 360) {
|
||||
return normalized ? [normalized] : [];
|
||||
}
|
||||
|
||||
const sentences = normalized
|
||||
.split(/(?<=[。!?!?;;])\s*/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
if (sentences.length <= 1) {
|
||||
return normalized.match(/[\s\S]{1,360}/g) ?? [];
|
||||
}
|
||||
|
||||
const paragraphs: string[] = [];
|
||||
let current = "";
|
||||
for (const sentence of sentences) {
|
||||
if ((current + sentence).length > 360 && current) {
|
||||
paragraphs.push(current.trim());
|
||||
current = "";
|
||||
}
|
||||
current += sentence;
|
||||
}
|
||||
if (current.trim()) {
|
||||
paragraphs.push(current.trim());
|
||||
}
|
||||
return paragraphs;
|
||||
function plainTextReferencePreview(text: string): string {
|
||||
return compactReferenceText(
|
||||
text
|
||||
.replace(/^---[\s\S]*?---\s*/m, "")
|
||||
.replace(/```[\s\S]*?```/g, " ")
|
||||
.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
|
||||
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
|
||||
.replace(/\[\[[^\]\s|]+[|\s]+([^\]]+)\]\]/g, "$1")
|
||||
.replace(/\[\[([^\]]+)\]\]/g, "$1")
|
||||
.replace(/^#{1,6}\s+/gm, "")
|
||||
.replace(/^\s*[-*+]\s+/gm, "")
|
||||
.replace(/^\s*\d+[.)]\s+/gm, "")
|
||||
.replace(/[>*_`~|]/g, " "),
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user