From 650102cedb5ed58e355978ac067ff120954b6a6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Tue, 26 May 2026 19:27:03 +0800 Subject: [PATCH] feat: improve citation display and skill persistence --- .../components/workspace/chats/chat-box.tsx | 92 +++-- .../workspace/citations/citation-link.tsx | 13 +- .../workspace/messages/message-group.tsx | 5 +- .../settings/appearance-settings-page.tsx | 126 +++++-- .../workspace/skill-display-adapter-panel.tsx | 48 ++- .../workspace/skill-display-sources.tsx | 84 ++++- frontend-web/src/core/settings/local.ts | 18 +- .../src/core/skill-display/display-adapter.ts | 34 +- .../src/core/skill-display/runtime.ts | 333 ++++++++++++++++-- frontend-web/src/core/skills/hooks.ts | 10 +- .../backend/app/gateway/routers/skills.py | 66 ++-- 11 files changed, 706 insertions(+), 123 deletions(-) diff --git a/frontend-web/src/components/workspace/chats/chat-box.tsx b/frontend-web/src/components/workspace/chats/chat-box.tsx index 9b41ee95..31ec10d9 100644 --- a/frontend-web/src/components/workspace/chats/chat-box.tsx +++ b/frontend-web/src/components/workspace/chats/chat-box.tsx @@ -40,9 +40,11 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({ const { thread } = useThread(); const pathname = usePathname(); const threadIdRef = useRef(threadId); + const artifactsCountRef = useRef(0); const layoutRef = useRef(null); const [sideTab, setSideTab] = useState("document"); const [referencesOpen, setReferencesOpen] = useState(true); + const [activeReferenceBatchId, setActiveReferenceBatchId] = useState(null); const [settings] = useLocalSettings(); const { skills } = useSkills(); @@ -61,24 +63,32 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({ () => (settings.display.ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()), [settings.display.ragModeEnabled, skills], ); - const latestHumanIndex = useMemo( - () => findLatestHumanMessageIndex(thread.values.messages ?? []), - [thread.values.messages], - ); - const displaySources = useMemo(() => { + const referenceBatches = useMemo(() => { if (!settings.display.ragModeEnabled) { return []; } const messages = thread.values.messages ?? []; - return collectRuntimeDisplaySources(messages, skillDisplayMap, { - afterMessageId: latestHumanIndex >= 0 ? messages[latestHumanIndex]?.id : undefined, - includeModes: ["citation"], - }); - }, [settings.display.ragModeEnabled, skillDisplayMap, latestHumanIndex, thread.values.messages]); - const referenceSources = useMemo( - () => displaySources.filter((source) => source.mode === "citation"), - [displaySources], - ); + return messages + .filter((message) => message.type === "ai" && message.id) + .map((message) => ({ + id: message.id!, + sources: collectRuntimeDisplaySources(messages, skillDisplayMap, { + afterMessageId: findPreviousHumanMessageId(messages, message.id), + beforeMessageId: message.id, + includeBeforeMessage: true, + includeModes: ["citation"], + }).filter((source) => source.mode === "citation"), + })) + .filter((batch) => batch.sources.length > 0); + }, [settings.display.ragModeEnabled, skillDisplayMap, thread.values.messages]); + const latestReferenceBatch = + referenceBatches.length > 0 + ? referenceBatches[referenceBatches.length - 1]! + : null; + const activeReferenceBatch = + referenceBatches.find((batch) => batch.id === activeReferenceBatchId) ?? + latestReferenceBatch; + const referenceSources = activeReferenceBatch?.sources ?? []; const hasCitationReferences = referenceSources.length > 0; useEffect(() => { if (hasCitationReferences) { @@ -86,7 +96,22 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({ } }, [hasCitationReferences]); useEffect(() => { - const handleShowReference = () => { + if (!latestReferenceBatch) { + setActiveReferenceBatchId(null); + return; + } + setActiveReferenceBatchId((current) => + current && referenceBatches.some((batch) => batch.id === current) + ? current + : latestReferenceBatch.id, + ); + }, [latestReferenceBatch?.id, referenceBatches]); + useEffect(() => { + const handleShowReference = (event: Event) => { + const detail = (event as CustomEvent<{ batchId?: string }>).detail; + if (detail?.batchId) { + setActiveReferenceBatchId(detail.batchId); + } setReferencesOpen(true); setSideTab("references"); }; @@ -98,12 +123,22 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({ useEffect(() => { if (threadIdRef.current !== threadId) { threadIdRef.current = threadId; + artifactsCountRef.current = 0; deselect(); setReferencesOpen(true); } // Update artifacts from the current thread - setArtifacts(thread.values.artifacts); + const nextArtifacts = 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) { + selectArtifact(nextArtifacts[nextArtifacts.length - 1]!); + setArtifactsOpen(true); + setSideTab("document"); + } // DO NOT automatically deselect the artifact when switching threads, because the artifacts auto discovering is not work now. // if ( @@ -142,10 +177,10 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({ const showTabs = !!selectedArtifact && hasCitationReferences; useEffect(() => { - if (!selectedArtifact && hasCitationReferences) { - setSideTab("references"); - } else if (selectedArtifact && !hasCitationReferences) { + if (selectedArtifact) { setSideTab("document"); + } else if (hasCitationReferences) { + setSideTab("references"); } }, [hasCitationReferences, selectedArtifact]); @@ -315,13 +350,22 @@ function ReferencePanel({ ); } -function findLatestHumanMessageIndex(messages: Message[]) { - for (let index = messages.length - 1; index >= 0; index--) { - if (messages[index]?.type === "human") { - return index; +function findPreviousHumanMessageId( + messages: Message[], + beforeMessageId?: string, +): string | undefined { + const beforeIndex = + beforeMessageId === undefined + ? messages.length + : messages.findIndex((message) => message.id === beforeMessageId); + const end = beforeIndex < 0 ? messages.length : beforeIndex; + for (let index = end - 1; index >= 0; index--) { + const message = messages[index]; + if (message?.type === "human") { + return message.id; } } - return -1; + return undefined; } export { ChatBox }; diff --git a/frontend-web/src/components/workspace/citations/citation-link.tsx b/frontend-web/src/components/workspace/citations/citation-link.tsx index 58b7c22a..438cbc99 100644 --- a/frontend-web/src/components/workspace/citations/citation-link.tsx +++ b/frontend-web/src/components/workspace/citations/citation-link.tsx @@ -28,20 +28,19 @@ export function CitationLink({ )} onClick={(event) => { event.preventDefault(); + const batchId = + event.currentTarget.closest("[data-reference-batch-id]") + ?.dataset.referenceBatchId; window.dispatchEvent( new CustomEvent(SHOW_REFERENCE_EVENT, { - detail: { referenceId }, + detail: { batchId, referenceId }, }), ); - const target = document.getElementById(`reference-${referenceId}`); const scroll = () => { + const target = document.getElementById(`reference-${referenceId}`); target?.scrollIntoView({ behavior: "smooth", block: "center" }); }; - if (target) { - scroll(); - } else { - window.setTimeout(scroll, 220); - } + window.setTimeout(scroll, 240); }} {...props} > diff --git a/frontend-web/src/components/workspace/messages/message-group.tsx b/frontend-web/src/components/workspace/messages/message-group.tsx index aabf5bc8..d354373d 100644 --- a/frontend-web/src/components/workspace/messages/message-group.tsx +++ b/frontend-web/src/components/workspace/messages/message-group.tsx @@ -51,7 +51,6 @@ import { useLocalSettings } from "@/core/settings"; import { useSkills } from "@/core/skills/hooks"; import { buildSkillDisplayMap, - findDisplayConfigForPayload, findDisplayConfigForTool, isDisplaySamplePayload, normalizeDefaultDisplaySources, @@ -256,9 +255,7 @@ function ToolCall({ result !== undefined && isDisplaySamplePayload(result) ? null : (findDisplayConfigForTool(name, skillDisplayMap, args) ?? - (result !== undefined - ? findDisplayConfigForPayload(name, result, skillDisplayMap) - : null)); + null); const displayItems = displayMatch && result !== undefined ? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({ diff --git a/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx b/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx index 1a35e372..b1498420 100644 --- a/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx +++ b/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx @@ -2,9 +2,11 @@ import { MoonIcon, SunIcon } from "lucide-react"; import { useMemo, type ComponentType, type SVGProps } from "react"; +import { toast } from "sonner"; import { useTheme } from "@/contexts/ThemeContext"; +import { Button } from "@/components/ui/button"; import { Select, SelectContent, @@ -14,9 +16,15 @@ import { } from "@/components/ui/select"; import { Separator } from "@/components/ui/separator"; import { Switch } from "@/components/ui/switch"; +import { Textarea } from "@/components/ui/textarea"; +import { getStoredAuth } from "@/core/auth"; import { enUS, isLocale, zhCN, type Locale } from "@/core/i18n"; import { useI18n } from "@/core/i18n/hooks"; import { useLocalSettings } from "@/core/settings"; +import { + useAppearanceSettings, + useUpdateAppearanceSettings, +} from "@/core/system-settings/hooks"; import { cn } from "@/lib/utils"; import { SettingsSection } from "./settings-section"; @@ -31,6 +39,24 @@ export function AppearanceSettingsPage() { const { theme, setTheme } = useTheme(); const currentTheme = theme; const [settings, setSettings] = useLocalSettings(); + const isAdmin = getStoredAuth()?.system_role === "admin"; + const { data: appearance } = useAppearanceSettings(); + const updateAppearance = useUpdateAppearanceSettings(); + const globalDefault = appearance?.default_theme ?? "light"; + + const handleSetGlobalDefault = async (value: "light" | "dark") => { + if (value === globalDefault) return; + try { + await updateAppearance.mutateAsync({ default_theme: value }); + toast.success( + value === "light" + ? "已将系统默认主题设置为浅色" + : "已将系统默认主题设置为深色", + ); + } catch (e) { + toast.error(e instanceof Error ? e.message : "保存失败"); + } + }; const themeOptions = useMemo( () => [ @@ -76,6 +102,43 @@ export function AppearanceSettingsPage() { + {isAdmin && ( + <> + + +
+ + +

+ 当前系统默认: + {globalDefault === "light" ? "浅色" : "深色"} +

+
+
+ + )} + - -
-
-
RAG 引用模式
-

- 开启后,已配置展示适配的技能结果会在问答中渲染为编号引用或列表,并支持点击跳转。 -

-
- - setSettings("display", { ragModeEnabled: checked }) - } - aria-label="RAG 引用模式" - /> -
-
- - ); -} + +
+
+
RAG 引用模式
+

+ 开启后,已配置展示适配的技能结果会在问答中渲染为编号引用或列表,并支持点击跳转。 +

+
+ + setSettings("display", { ragModeEnabled: checked }) + } + aria-label="RAG 引用模式" + /> +
+ {isAdmin && ( +
+
+
参考文献去除词
+

+ 管理员配置。每行一个词或短句,参考文献卡片和详情弹框会自动移除这些广告词、导航词或免责声明。 +

+
+