diff --git a/.gitignore b/.gitignore index 532043d8..ba42257b 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,8 @@ frontend-web/.vscode/ # Local runtime logs and pid files .runtime/ +logs/ +offline-backend-20260512/backend/logs/ # Frontend local/runtime artifacts frontend-web/.env @@ -81,3 +83,6 @@ hermes-agent-main/ offline-backend-20260512/backend/tests/* deploy .history + +# Frontend skills components must be tracked (excluded from the broad skills/ ignore above) +!frontend-web/src/components/workspace/skills/ diff --git a/frontend-web/src/App.tsx b/frontend-web/src/App.tsx index 155c3f64..ea03c986 100644 --- a/frontend-web/src/App.tsx +++ b/frontend-web/src/App.tsx @@ -3,6 +3,7 @@ import { Navigate, Route, Routes } from "react-router-dom"; import { ThemeProvider as NextThemesProvider } from "next-themes"; import { QueryClientProvider } from "@/components/query-client-provider"; +import { AuthSyncWatcher } from "@/core/auth/sync"; import { I18nProvider } from "@/core/i18n/context"; import { detectLocale } from "@/core/i18n/locale"; import { ChatProvider } from "@/contexts/ChatContext"; @@ -31,13 +32,18 @@ function AppProviders({ children }: { children: React.ReactNode }) { disableTransitionOnChange forcedTheme={forcedTheme} > - - - - {children} - - - + {/* QueryClientProvider must wrap ThemeProvider because the custom + ThemeProvider reads the admin-configured default via useQuery. */} + + + + + + {children} + + + + ); } diff --git a/frontend-web/src/components/page-sidebar.tsx b/frontend-web/src/components/page-sidebar.tsx index b4e32a8f..810e9e74 100644 --- a/frontend-web/src/components/page-sidebar.tsx +++ b/frontend-web/src/components/page-sidebar.tsx @@ -26,6 +26,7 @@ import { ListTodo, MessageSquareCode, MessageSquarePlus, + ShieldCheck, Network, NotebookPen, Plus, @@ -175,7 +176,7 @@ const menuItems: MenuItem[] = [ { id: "mgmt-memory", label: "记忆管理", icon: Brain, path: "/page/strategy/memory" }, { id: "mgmt-tasks", - label: "任务管理", + label: "定时任务", icon: ListChecks, path: "/page/strategy/scheduled/tasks", }, @@ -193,6 +194,13 @@ const menuItems: MenuItem[] = [ path: "/page/strategy/settings/prompt-prefix", adminOnly: true, }, + { + id: "mgmt-private-square", + label: "私密广场配置", + icon: ShieldCheck, + path: "/page/strategy/settings/private-square", + adminOnly: true, + }, { id: "mgmt-llm-metrics", label: "大模型调用统计", diff --git a/frontend-web/src/components/workspace/agents/agent-card.tsx b/frontend-web/src/components/workspace/agents/agent-card.tsx index 7a2c63fc..7f6df666 100644 --- a/frontend-web/src/components/workspace/agents/agent-card.tsx +++ b/frontend-web/src/components/workspace/agents/agent-card.tsx @@ -6,6 +6,7 @@ import { InfoIcon, LockIcon, MessageSquareIcon, + ShieldIcon, SparklesIcon, SquarePenIcon, TagsIcon, @@ -43,6 +44,7 @@ import { useAgent, useDeleteAgent, useSetAgentFeatured, + useSetAgentPrivate, useUpdateAgent, } from "@/core/agents"; import type { Agent } from "@/core/agents"; @@ -62,6 +64,7 @@ export function AgentCard({ agent }: AgentCardProps) { const deleteAgent = useDeleteAgent(); const updateAgent = useUpdateAgent(); const setFeatured = useSetAgentFeatured(); + const setPrivate = useSetAgentPrivate(); const { skills, isLoading: isSkillsLoading } = useSkills(); const [deleteOpen, setDeleteOpen] = useState(false); const [editOpen, setEditOpen] = useState(false); @@ -70,6 +73,7 @@ export function AgentCard({ agent }: AgentCardProps) { const [editDescription, setEditDescription] = useState(agent.description ?? ""); const [editSoul, setEditSoul] = useState(agent.soul ?? ""); const [editPublished, setEditPublished] = useState(agent.published); + const [editIsPrivate, setEditIsPrivate] = useState(agent.is_private); const [selectedSkills, setSelectedSkills] = useState( agent.skills ?? [], ); @@ -89,11 +93,12 @@ export function AgentCard({ agent }: AgentCardProps) { const isOwned = !isBuiltin && agent.user_id !== null && agent.user_id === currentUserId; const isFromOthers = !isBuiltin && !isOwned; - // System admins are allowed to edit and delete built-in agents. + // System admins are allowed to edit and delete any agent (built-in or owned + // by another user) so the admin console can manage the full catalog. // Publish-toggle stays owner-only — built-ins are inherently visible to // everyone so toggling published is moot. - const canEdit = isOwned || (isBuiltin && isAdmin); - const canDelete = isOwned || (isBuiltin && isAdmin); + const canEdit = isOwned || isAdmin; + const canDelete = isOwned || isAdmin; useEffect(() => { if (!editOpen) { @@ -102,9 +107,11 @@ export function AgentCard({ agent }: AgentCardProps) { setEditName(agent.name); setEditDescription(agent.description ?? ""); setEditPublished(agent.published); + setEditIsPrivate(agent.is_private); setSelectedSkills(agent.skills ?? []); }, [ agent.description, + agent.is_private, agent.name, agent.published, agent.skills, @@ -145,6 +152,10 @@ export function AgentCard({ agent }: AgentCardProps) { toast.error(t.agents.nameStepInvalidError ?? "名称不能为空"); return; } + if (editIsPrivate && editPublished) { + toast.warning("私密广场与公开发布互斥,请只勾选其一"); + return; + } try { await updateAgent.mutateAsync({ id: agent.id, @@ -152,7 +163,10 @@ export function AgentCard({ agent }: AgentCardProps) { name: trimmedName, description: editDescription, soul: editSoul, - published: editPublished, + // Private agents cannot also be published — force the value the + // backend will see, regardless of any stale state in editPublished. + published: editIsPrivate ? false : editPublished, + is_private: editIsPrivate, skills: selectedSkills, }, }); @@ -164,6 +178,13 @@ export function AgentCard({ agent }: AgentCardProps) { } async function handleTogglePublished() { + // Private and public squares are mutually exclusive — a private agent + // can't simultaneously be published to everyone. Block the toggle + // instead of silently moving the agent between squares. + if (!agent.published && agent.is_private) { + toast.warning("该智能体已加入私密广场,不可发布"); + return; + } try { await updateAgent.mutateAsync({ id: agent.id, @@ -175,6 +196,27 @@ export function AgentCard({ agent }: AgentCardProps) { } } + async function handleTogglePrivate() { + const turningOn = !agent.is_private; + try { + if (isOwned) { + // Owners can re-categorize their own agents through the normal update + // endpoint — keeps publish/private edits in one transaction. + await updateAgent.mutateAsync({ + id: agent.id, + request: { is_private: turningOn }, + }); + } else { + // Admins can move any agent (built-in or other users') via the + // dedicated admin endpoint. + await setPrivate.mutateAsync({ id: agent.id, isPrivate: turningOn }); + } + toast.success(turningOn ? "已加入私密广场" : "已移出私密广场"); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } + } + async function handleToggleFeatured() { // One-click on/off — ordering is handled by the dedicated 管理 Agent 列表 // dialog. Featured agents get appended to the end on add (Date.now() is a @@ -249,7 +291,7 @@ export function AgentCard({ agent }: AgentCardProps) { 来自他人 )} - {isOwned && agent.published && ( + {isOwned && agent.published && !agent.is_private && ( )} + {agent.is_private && ( + + + 私密 + + )} {agent.featured_order != null && ( )} - - + {(canEdit || canDelete || isOwned || isAdmin) && ( -
+
{isAdmin && ( + )} {canEdit && ( @@ -491,12 +561,41 @@ export function AgentCard({ agent }: AgentCardProps) {

公开发布

- 打开后,该智能体对所有用户可见。 + {editIsPrivate + ? "已加入私密广场,不可发布;请先关闭私密广场。" + : "打开后,该智能体对所有用户可见。"}

{ + if (editIsPrivate) { + toast.warning("该智能体已加入私密广场,不可发布"); + return; + } + setEditPublished(v); + }} + /> +
+
+
+

加入私密广场

+

+ 打开后,该智能体仅在私密广场显示,需输入密码才能查看;与「公开发布」互斥。 +

+
+ { + setEditIsPrivate(v); + if (v) { + // Mutual-exclusion: switching the agent into the private + // square automatically clears the public-publish flag so + // the user can't end up with a half-and-half state. + setEditPublished(false); + } + }} />
diff --git a/frontend-web/src/components/workspace/agents/agent-gallery.tsx b/frontend-web/src/components/workspace/agents/agent-gallery.tsx index cdce912f..10001772 100644 --- a/frontend-web/src/components/workspace/agents/agent-gallery.tsx +++ b/frontend-web/src/components/workspace/agents/agent-gallery.tsx @@ -1,44 +1,138 @@ "use client"; -import { BotIcon, PlusIcon } from "lucide-react"; +import { BotIcon, LockIcon, PlusIcon, ShieldIcon } from "lucide-react"; import { useRouter } from "next/navigation"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar"; -import { useAgents } from "@/core/agents"; +import { useAgents, usePrivateAgents } from "@/core/agents"; +import type { Agent } from "@/core/agents"; import { getStoredAuth } from "@/core/auth"; import { useI18n } from "@/core/i18n/hooks"; +import { usePrivateSquareStatus } from "@/core/system-settings/hooks"; +import { desensitize } from "@/lib/desensitize"; import { AgentCard } from "./agent-card"; +// Within a single tab the verified password is kept in sessionStorage so a +// refresh / route navigation does not re-prompt. Cleared on tab close, so the +// password never persists beyond the browsing session. +const PRIVATE_SQUARE_PASSWORD_KEY = "deer-flow.private-square-password"; + export function AgentGallery() { const { t } = useI18n(); const router = useRouter(); - const [activeTab, setActiveTab] = useState<"all" | "mine">("mine"); + const [activeTab, setActiveTab] = useState<"all" | "mine" | "private">( + "mine", + ); const [search, setSearch] = useState(""); const [tagIds, setTagIds] = useState([]); - // Name + tag filtering is resolved server-side via the /api/agents query. - const { agents, isLoading } = useAgents({ search, tagIds }); + // Tag filtering stays server-side; name search must run on the *desensitized* + // form because that's what the user sees on the card. If we sent the search + // string to the backend it would compare against the raw stored name and + // miss every desensitized hit (e.g. typing "TS分析" wouldn't match the + // stored "态势分析"). + const { agents, isLoading } = useAgents({ tagIds }); + + const needle = search.trim().toLowerCase(); + const matchesSearch = (a: Agent) => + !needle || desensitize(a.name).toLowerCase().includes(needle); const storedAuth = useMemo(() => getStoredAuth(), []); const currentUserId = storedAuth?.user_id ?? null; + const { data: privateStatus } = usePrivateSquareStatus(); + const privateFeatureAvailable = !!privateStatus?.enabled; + const privateFeatureUsable = + privateFeatureAvailable && !!privateStatus?.password_set; + + const [privatePassword, setPrivatePassword] = useState(() => { + if (typeof window === "undefined") return null; + return window.sessionStorage.getItem(PRIVATE_SQUARE_PASSWORD_KEY); + }); + const [passwordInput, setPasswordInput] = useState(""); + + const { + agents: privateAgents, + isLoading: isPrivateLoading, + error: privateError, + } = usePrivateAgents(privatePassword); + + // If the server reports the stored password no longer works (likely the + // admin rotated it), drop it from session storage and re-prompt. + useEffect(() => { + if (privatePassword && privateError) { + window.sessionStorage.removeItem(PRIVATE_SQUARE_PASSWORD_KEY); + setPrivatePassword(null); + toast.error("私密广场密码已失效,请重新输入"); + } + }, [privateError, privatePassword]); + + // The "广场" tab must never surface private-square agents, even to their + // own owner — moving an agent into the private square is supposed to hide + // it from the public square entirely. (The backend listing endpoint + // already filters them out for non-owners; this extra clause covers the + // owner's own private agents, which still come back in the unified list + // because owners always see their own.) const allAgents = useMemo( - () => agents.filter((a) => a.builtin || a.published), - [agents], + () => + agents.filter( + (a) => (a.builtin || a.published) && !a.is_private && matchesSearch(a), + ), + // matchesSearch closes over `needle`; depending on `search` is sufficient + // and avoids re-running on every render via the function reference. + // eslint-disable-next-line react-hooks/exhaustive-deps + [agents, search], ); const myAgents = useMemo( - () => agents.filter((a) => a.user_id !== null && a.user_id === currentUserId), - [agents, currentUserId], + () => + agents.filter( + (a) => + a.user_id !== null && + a.user_id === currentUserId && + matchesSearch(a), + ), + // eslint-disable-next-line react-hooks/exhaustive-deps + [agents, currentUserId, search], ); + const filteredPrivateAgents = useMemo(() => { + const wanted = new Set(tagIds); + return privateAgents.filter((a) => { + if (!matchesSearch(a)) return false; + if (wanted.size > 0) { + if (!(a.tags ?? []).some((tg) => wanted.has(tg.id))) return false; + } + return true; + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [privateAgents, search, tagIds]); + const handleNewAgent = () => { router.push("/page/workspace/agents/new"); }; + const handleSubmitPassword = () => { + const trimmed = passwordInput.trim(); + if (!trimmed) { + toast.error("请输入密码"); + return; + } + window.sessionStorage.setItem(PRIVATE_SQUARE_PASSWORD_KEY, trimmed); + setPrivatePassword(trimmed); + setPasswordInput(""); + }; + + const handleLockPrivate = () => { + window.sessionStorage.removeItem(PRIVATE_SQUARE_PASSWORD_KEY); + setPrivatePassword(null); + }; + const renderGrid = (list: typeof agents) => (
{list.map((agent) => ( @@ -65,6 +159,78 @@ export function AgentGallery() {
); + const renderPrivateTab = () => { + if (!privateFeatureAvailable) { + return ( +
+
+ +
+

私密广场尚未开启

+

+ 请管理员前往「综合管理 → 私密广场配置」开启该功能并设置密码。 +

+
+ ); + } + + if (!privateFeatureUsable) { + return ( +
+
+ +
+

私密广场未配置密码

+

+ 请管理员在私密广场配置页设置一个访问密码。 +

+
+ ); + } + + if (!privatePassword) { + return ( +
+
+ +
+
+

私密广场

+

+ 请输入访问密码,验证通过后才能查看其中的智能体。 +

+
+
+ setPasswordInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleSubmitPassword(); + }} + /> + +
+
+ ); + } + + if (isPrivateLoading) { + return ( +
+ {t.common.loading} +
+ ); + } + + if (filteredPrivateAgents.length === 0) { + return renderEmpty("私密广场暂时还没有智能体。"); + } + + return renderGrid(filteredPrivateAgents); + }; + return (
{/* Page header */} @@ -84,14 +250,35 @@ export function AgentGallery() { {/* Tabs */} setActiveTab(v as "all" | "mine")} + onValueChange={(v) => + setActiveTab(v as "all" | "mine" | "private") + } className="flex flex-1 flex-col overflow-hidden" >
- - 我的 - 广场 - +
+ + 我的 + 广场 + {privateFeatureAvailable && ( + + + 私密广场 + + )} + + {activeTab === "private" && privatePassword && ( + + )} +
+ + {privateFeatureAvailable && ( + + {renderPrivateTab()} + + )}
); diff --git a/frontend-web/src/components/workspace/agents/agent-manage-dialog.tsx b/frontend-web/src/components/workspace/agents/agent-manage-dialog.tsx index fab434ed..03231c51 100644 --- a/frontend-web/src/components/workspace/agents/agent-manage-dialog.tsx +++ b/frontend-web/src/components/workspace/agents/agent-manage-dialog.tsx @@ -61,7 +61,12 @@ export function AgentManageDialog({ const candidates = useMemo(() => { const existing = new Set(agents.map((a) => a.id)); - return allAgents.filter((a) => a.published && !existing.has(a.id)); + // Private-square agents are hidden from the homepage selector even if + // they're published — the backend ``list_featured`` filter would drop + // them again on the way out, so offering them here is just confusing. + return allAgents.filter( + (a) => a.published && !a.is_private && !existing.has(a.id), + ); }, [agents, allAgents]); const busy = setFeaturedMutation.isPending || reorderMutation.isPending; diff --git a/frontend-web/src/components/workspace/chats/chat-box.tsx b/frontend-web/src/components/workspace/chats/chat-box.tsx index e1ad3f1a..7d776cc7 100644 --- a/frontend-web/src/components/workspace/chats/chat-box.tsx +++ b/frontend-web/src/components/workspace/chats/chat-box.tsx @@ -1,3 +1,4 @@ +import type { Message } from "@langchain/langgraph-sdk"; import { FilesIcon, XIcon } from "lucide-react"; import { usePathname } from "next/navigation"; import { useEffect, useMemo, useRef, useState } from "react"; @@ -10,6 +11,16 @@ import { ResizablePanel, ResizablePanelGroup, } from "@/components/ui/resizable"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +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 { + buildSkillDisplayMap, + collectRuntimeDisplaySources, +} from "@/core/skill-display/runtime"; import { env } from "@/env"; import { cn } from "@/lib/utils"; @@ -19,9 +30,38 @@ import { useArtifacts, } from "../artifacts"; import { useThread } from "../messages/context"; +import { SkillDisplaySources } from "../skill-display-sources"; const CLOSE_MODE = { chat: 100, artifacts: 0 }; const OPEN_MODE = { chat: 60, artifacts: 40 }; +const SHOW_REFERENCE_EVENT = "workspace:show-reference"; + +type BackendReferenceSource = Parameters[0]["sources"][number]; + +type BackendReferenceBatch = { + id: string; + assistant_message_id?: string; + sources: BackendReferenceSource[]; +}; + +function logReferenceDebug( + event: string, + threadId: string, + payload: Record, +) { + console.info(`[RAG引用调试] ${event}`, payload); + void apiFetch(`${getBackendBaseURL()}/api/threads/reference-debug-log`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + event, + thread_id: threadId, + payload, + }), + }).catch(() => { + // Debug logging must never affect the chat flow. + }); +} const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({ children, @@ -30,7 +70,20 @@ 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 latestReferenceBatchIdRef = useRef(null); + const manualReferenceBatchRef = useRef(false); + const referenceDebugSignatureRef = useRef(""); + const [sideTab, setSideTab] = useState("document"); + const [referencesOpen, setReferencesOpen] = useState(true); + const [activeReferenceBatchId, setActiveReferenceBatchId] = useState(null); + const [pinnedReferenceId, setPinnedReferenceId] = useState(null); + const [backendReferenceBatches, setBackendReferenceBatches] = useState([]); + const [settings] = useLocalSettings(); + const ragModeEnabled = + settings.display.ragModeEnabled || isThreadRagModeLocked(threadId); + const { skills } = useSkills(); const { artifacts, @@ -43,14 +96,176 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({ } = useArtifacts(); const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true); + const skillDisplayMap = useMemo( + () => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()), + [ragModeEnabled, skills], + ); + const fallbackReferenceBatches = useMemo(() => { + if (!ragModeEnabled) { + return []; + } + return buildTurnReferenceBatches( + thread.values.messages ?? [], + skillDisplayMap, + thread.isLoading, + ); + }, [ragModeEnabled, skillDisplayMap, thread.isLoading, thread.values.messages]); + useEffect(() => { + if (!ragModeEnabled) { + setBackendReferenceBatches([]); + return; + } + if (thread.isLoading) { + return; + } + const controller = new AbortController(); + logReferenceDebug("开始拉取后端参考文献", threadId, { + messageCount: thread.values.messages?.length ?? 0, + }); + apiFetch( + `${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/reference-batches`, + { signal: controller.signal }, + ) + .then(async (response) => { + if (!response.ok) { + throw new Error(`Failed to load reference batches: ${response.status}`); + } + return (await response.json()) as { batches?: BackendReferenceBatch[] }; + }) + .then((data) => { + const batches = data.batches ?? []; + logReferenceDebug("后端参考文献返回", threadId, { + batchCount: batches.length, + sourceCounts: batches.map((batch) => batch.sources?.length ?? 0), + batchIds: batches.map((batch) => batch.id), + }); + setBackendReferenceBatches(batches); + }) + .catch((error) => { + if (!controller.signal.aborted) { + console.warn("Failed to load backend reference batches", error); + } + }); + return () => controller.abort(); + }, [ragModeEnabled, thread.isLoading, threadId, thread.values.messages]); + const adaptedBackendReferenceBatches = useMemo( + () => + adaptBackendReferenceBatches( + backendReferenceBatches, + fallbackReferenceBatches, + skillDisplayMap, + ), + [backendReferenceBatches, fallbackReferenceBatches, skillDisplayMap], + ); + const referenceBatches = useMemo( + () => + mergeReferenceBatches( + adaptedBackendReferenceBatches, + fallbackReferenceBatches, + ), + [adaptedBackendReferenceBatches, fallbackReferenceBatches], + ); + useEffect(() => { + if (!ragModeEnabled) { + return; + } + const signature = JSON.stringify({ + backend: backendReferenceBatches.map((batch) => batch.sources?.length ?? 0), + adaptedBackend: adaptedBackendReferenceBatches.map((batch) => batch.sources?.length ?? 0), + fallback: fallbackReferenceBatches.map((batch) => batch.sources?.length ?? 0), + final: referenceBatches.map((batch) => batch.sources?.length ?? 0), + }); + if (referenceDebugSignatureRef.current === signature) { + return; + } + referenceDebugSignatureRef.current = signature; + logReferenceDebug("前端参考文献来源选择", threadId, { + using: backendReferenceBatches.length > 0 ? "后端接口" : "前端兜底", + backendBatchCount: backendReferenceBatches.length, + backendSourceCounts: backendReferenceBatches.map((batch) => batch.sources?.length ?? 0), + adaptedBackendSourceCounts: adaptedBackendReferenceBatches.map((batch) => batch.sources?.length ?? 0), + fallbackBatchCount: fallbackReferenceBatches.length, + fallbackSourceCounts: fallbackReferenceBatches.map((batch) => batch.sources?.length ?? 0), + finalBatchCount: referenceBatches.length, + finalSourceCounts: referenceBatches.map((batch) => batch.sources?.length ?? 0), + }); + }, [adaptedBackendReferenceBatches, backendReferenceBatches, fallbackReferenceBatches, ragModeEnabled, referenceBatches, threadId]); + 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) { + setReferencesOpen(true); + } + }, [hasCitationReferences]); + useEffect(() => { + if (!latestReferenceBatch) { + setActiveReferenceBatchId(null); + latestReferenceBatchIdRef.current = null; + manualReferenceBatchRef.current = false; + return; + } + const previousLatestId = latestReferenceBatchIdRef.current; + latestReferenceBatchIdRef.current = latestReferenceBatch.id; + setActiveReferenceBatchId((current) => + current && + referenceBatches.some((batch) => batch.id === current) && + manualReferenceBatchRef.current && + current !== previousLatestId + ? current + : latestReferenceBatch.id, + ); + }, [latestReferenceBatch?.id, referenceBatches]); + useEffect(() => { + const handleShowReference = (event: Event) => { + const detail = (event as CustomEvent<{ batchId?: string; referenceId?: string }>).detail; + if (detail?.batchId) { + manualReferenceBatchRef.current = true; + setActiveReferenceBatchId(detail.batchId); + } + setPinnedReferenceId(null); + window.setTimeout(() => { + setPinnedReferenceId(detail?.referenceId ?? null); + }, 0); + setReferencesOpen(true); + setSideTab("references"); + }; + window.addEventListener(SHOW_REFERENCE_EVENT, handleShowReference); + return () => { + window.removeEventListener(SHOW_REFERENCE_EVENT, handleShowReference); + }; + }, []); useEffect(() => { if (threadIdRef.current !== threadId) { threadIdRef.current = threadId; + artifactsCountRef.current = 0; deselect(); + setReferencesOpen(false); + setActiveReferenceBatchId(null); + setPinnedReferenceId(null); + setBackendReferenceBatches([]); + latestReferenceBatchIdRef.current = null; + manualReferenceBatchRef.current = false; + referenceDebugSignatureRef.current = ""; } // 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 ( @@ -85,6 +300,16 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({ } return artifactsOpen; }, [artifactsOpen, artifacts]); + const sidePanelOpen = artifactPanelOpen || (hasCitationReferences && referencesOpen); + const showTabs = !!selectedArtifact && hasCitationReferences; + + useEffect(() => { + if (selectedArtifact) { + setSideTab("document"); + } else if (hasCitationReferences) { + setSideTab("references"); + } + }, [hasCitationReferences, selectedArtifact]); const resizableIdBase = useMemo(() => { const raw = pathname @@ -95,13 +320,13 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({ useEffect(() => { if (layoutRef.current) { - if (artifactPanelOpen) { + if (sidePanelOpen) { layoutRef.current.setLayout(OPEN_MODE); } else { layoutRef.current.setLayout(CLOSE_MODE); } } - }, [artifactPanelOpen]); + }, [sidePanelOpen]); return ( = ({ id={`${resizableIdBase}-separator`} className={cn( "opacity-33 hover:opacity-100", - !artifactPanelOpen && "pointer-events-none opacity-0", + !sidePanelOpen && "pointer-events-none opacity-0", )} />
- {selectedArtifact ? ( + {showTabs ? ( + +
+ + 文档 + 参考文献 + + +
+ + + + + + +
+ ) : selectedArtifact ? ( + ) : hasCitationReferences ? ( +
+
+ +
+ +
) : (
@@ -180,4 +456,335 @@ const ChatBox: React.FC<{ children: React.ReactNode; threadId: string }> = ({ ); }; +function ReferencePanel({ + highlightedReferenceId, + sources, +}: { + highlightedReferenceId?: string | null; + sources: Parameters[0]["sources"]; +}) { + const orderedSources = useMemo(() => { + if (!highlightedReferenceId) { + return sources; + } + const index = Number(highlightedReferenceId); + if (!Number.isFinite(index)) { + return sources; + } + const selected = sources.find((source) => source.index === index); + if (!selected) { + return sources; + } + return [selected, ...sources.filter((source) => source.index !== index)]; + }, [highlightedReferenceId, sources]); + + return ( +
+
+
+

+ 参考文献 +

+ + {sources.length} 条 + +
+

+ 点击编号或卡片可查看引用详情。 +

+
+
+ +
+
+ ); +} + +function adaptBackendReferenceBatches( + backendBatches: BackendReferenceBatch[], + configuredBatches: BackendReferenceBatch[], + skillDisplayMap: ReturnType, +): BackendReferenceBatch[] { + if (backendBatches.length === 0) { + return backendBatches; + } + return backendBatches.map((backendBatch, batchIndex) => { + const configuredBatch = + configuredBatches.find( + (batch) => + batch.id === backendBatch.id || + batch.assistant_message_id === backendBatch.assistant_message_id, + ) ?? configuredBatches[batchIndex]; + const configuredSources = configuredBatch?.sources ?? []; + const configuredByIndex = new Map( + configuredSources.map((source) => [source.index, source]), + ); + const configuredByKey = new Map( + configuredSources.map((source) => [referenceSourceKey(source), source]), + ); + return { + ...backendBatch, + sources: backendBatch.sources.map((backendSource, sourceIndex) => { + const configuredSource = + configuredByIndex.get(backendSource.index) ?? + configuredByKey.get(referenceSourceKey(backendSource)) ?? + configuredSources[sourceIndex] ?? + adaptBackendSourceWithSkillDisplay(backendSource, skillDisplayMap); + if (!configuredSource) { + return backendSource; + } + return { + ...backendSource, + ...configuredSource, + index: backendSource.index, + mode: + backendSource.mode !== "citation" + ? backendSource.mode + : configuredSource.mode || backendSource.mode, + raw: backendSource.raw ?? configuredSource.raw, + }; + }), + }; + }); +} + +function mergeReferenceBatches( + backendBatches: BackendReferenceBatch[], + fallbackBatches: BackendReferenceBatch[], +): BackendReferenceBatch[] { + if (backendBatches.length === 0) { + return fallbackBatches; + } + if (fallbackBatches.length === 0) { + return backendBatches; + } + const merged = [...backendBatches]; + const knownIds = new Set( + backendBatches.flatMap((batch) => + [batch.id, batch.assistant_message_id].filter(Boolean), + ), + ); + for (const fallbackBatch of fallbackBatches) { + if ( + knownIds.has(fallbackBatch.id) || + (fallbackBatch.assistant_message_id && + knownIds.has(fallbackBatch.assistant_message_id)) + ) { + continue; + } + merged.push(fallbackBatch); + } + return merged; +} + +function adaptBackendSourceWithSkillDisplay( + source: BackendReferenceSource, + skillDisplayMap: ReturnType, +): BackendReferenceSource | null { + const raw = source.raw; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + return null; + } + const rawItem = raw as Record; + const match = findBackendSourceDisplay(source, skillDisplayMap); + if (!match) { + return null; + } + const normalized = normalizeDisplaySources( + buildPayloadAtPath(match.display.resultPath || "results", [rawItem]), + match.display, + )[0]; + if (!normalized) { + return null; + } + return { + ...source, + ...normalized, + skillName: match.skillName, + mode: match.display.mode, + tableColumns: match.display.tableColumns, + index: source.index, + raw: source.raw, + }; +} + +function findBackendSourceDisplay( + source: BackendReferenceSource, + skillDisplayMap: ReturnType, +) { + const direct = skillDisplayMap.get(source.skillName); + if (direct) { + return { skillName: source.skillName, display: direct }; + } + const normalizedSource = normalizeSkillName(source.skillName); + for (const [skillName, display] of skillDisplayMap) { + const normalizedSkill = normalizeSkillName(skillName); + if ( + normalizedSource === normalizedSkill || + normalizedSource.endsWith(normalizedSkill) || + normalizedSource.includes(normalizedSkill) + ) { + return { skillName, display }; + } + } + return null; +} + +function normalizeSkillName(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9]+/g, ""); +} + +function buildPayloadAtPath(path: string, items: Record[]) { + const parts = path.split(".").map((part) => part.trim()).filter(Boolean); + if (parts.length === 0) { + return items; + } + const root: Record = {}; + let cursor = root; + for (let index = 0; index < parts.length; index++) { + const part = parts[index]!; + if (index === parts.length - 1) { + cursor[part] = items; + } else { + const next: Record = {}; + cursor[part] = next; + cursor = next; + } + } + return root; +} + +function referenceSourceKey(source: BackendReferenceSource): string { + const raw = source.raw ?? {}; + const rawRecord = + raw && typeof raw === "object" && !Array.isArray(raw) + ? (raw as Record) + : {}; + const id = + source.id || + source.url || + stringifyKeyValue(rawRecord.recUuid) || + stringifyKeyValue(rawRecord.uuid) || + stringifyKeyValue(rawRecord.docId) || + stringifyKeyValue(rawRecord.id); + if (id) { + return id.trim().toLowerCase(); + } + return [source.title, source.source, source.time, source.content, source.snippet] + .filter(Boolean) + .join("|") + .trim() + .toLowerCase(); +} + +function stringifyKeyValue(value: unknown): string | undefined { + if (typeof value === "string" || typeof value === "number") { + const text = String(value).trim(); + return text || undefined; + } + return undefined; +} + +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 undefined; +} + +function buildTurnReferenceBatches( + messages: Message[], + skillDisplayMap: ReturnType, + isLoading: boolean, +): BackendReferenceBatch[] { + const batches: BackendReferenceBatch[] = []; + for (let index = 0; index < messages.length; index++) { + const human = messages[index]; + if (human?.type !== "human" || !human.id) { + continue; + } + const nextHumanIndex = findNextHumanIndex(messages, index + 1); + if (nextHumanIndex === -1 && isLoading) { + continue; + } + const endIndex = nextHumanIndex === -1 ? messages.length : nextHumanIndex; + const assistant = findLastAssistantAnswer(messages, index + 1, endIndex); + if (!assistant?.id) { + continue; + } + const sources = collectRuntimeDisplaySources(messages, skillDisplayMap, { + afterMessageId: human.id, + beforeMessageId: + nextHumanIndex === -1 ? undefined : messages[nextHumanIndex]?.id, + includeBeforeMessage: false, + }).filter((source) => source.mode !== "none"); + if (sources.length > 0) { + batches.push({ + id: assistant.id, + assistant_message_id: assistant.id, + sources, + }); + } + } + return batches; +} + +function findNextHumanIndex(messages: Message[], start: number): number { + for (let index = start; index < messages.length; index++) { + if (messages[index]?.type === "human") { + return index; + } + } + return -1; +} + +function findLastAssistantAnswer( + messages: Message[], + start: number, + end: number, +): Message | null { + for (let index = end - 1; index >= start; index--) { + const message = messages[index]; + if ( + message?.type === "ai" && + message.id && + hasAssistantAnswerContent(message) + ) { + return message; + } + } + return null; +} + +function hasAssistantAnswerContent(message: Message): boolean { + const content = message.content; + if (typeof content === "string") { + return content.trim().length > 0; + } + if (!Array.isArray(content)) { + return false; + } + return content.some((part) => { + if (!part || typeof part !== "object") { + return false; + } + const block = part as Record; + return block.type === "text" && typeof block.text === "string" && block.text.trim().length > 0; + }); +} + export { ChatBox }; diff --git a/frontend-web/src/components/workspace/chats/use-thread-chat.ts b/frontend-web/src/components/workspace/chats/use-thread-chat.ts index 54145f96..9bfaada8 100644 --- a/frontend-web/src/components/workspace/chats/use-thread-chat.ts +++ b/frontend-web/src/components/workspace/chats/use-thread-chat.ts @@ -2,12 +2,19 @@ import { useParams, usePathname, useSearchParams } from "next/navigation"; import { useEffect, useState } from "react"; +import { useLocation } from "react-router-dom"; import { uuid } from "@/core/utils/uuid"; export function useThreadChat() { const { thread_id: threadIdFromPath } = useParams<{ thread_id: string }>(); const pathname = usePathname(); + // ``location.key`` is regenerated on every react-router navigation, even when + // the resolved pathname is identical. We depend on it so that clicking + // "新对话" while already at /chats/new still triggers a fresh thread reset + // (otherwise navigate-to-same-path is silently a no-op and the in-flight + // conversation would persist on screen). + const locationKey = useLocation().key; const searchParams = useSearchParams(); const [threadId, setThreadId] = useState(() => { @@ -37,7 +44,7 @@ export function useThreadChat() { } setIsNewThread(false); setThreadId(threadIdFromPath); - }, [pathname, threadIdFromPath]); + }, [pathname, threadIdFromPath, locationKey]); const isMock = searchParams.get("mock") === "true"; return { threadId, setThreadId, isNewThread, setIsNewThread, isMock }; } diff --git a/frontend-web/src/components/workspace/citations/citation-link.tsx b/frontend-web/src/components/workspace/citations/citation-link.tsx index aecc71a8..438cbc99 100644 --- a/frontend-web/src/components/workspace/citations/citation-link.tsx +++ b/frontend-web/src/components/workspace/citations/citation-link.tsx @@ -1,82 +1,77 @@ -import { ExternalLinkIcon } from "lucide-react"; -import type { ComponentProps } from "react"; - -import { Badge } from "@/components/ui/badge"; -import { - HoverCard, - HoverCardContent, - HoverCardTrigger, -} from "@/components/ui/hover-card"; -import { cn } from "@/lib/utils"; - -export function CitationLink({ - href, - children, - ...props -}: ComponentProps<"a">) { - const domain = extractDomain(href ?? ""); - - // Priority: children > domain - const childrenText = - typeof children === "string" - ? children.replace(/^citation:\s*/i, "") - : null; - const isGenericText = childrenText === "Source" || childrenText === "来源"; - const displayText = (!isGenericText && childrenText) ?? domain; - - return ( - - - e.stopPropagation()} - {...props} - > - - {displayText} - - - - - -
-
- {displayText && ( -

- {displayText} -

- )} - {href && ( -

- {href} -

- )} -
- - Visit source - - -
-
-
- ); -} - -function extractDomain(url: string): string { - try { - return new URL(url).hostname.replace(/^www\./i, ""); - } catch { - return url; - } -} +import type { ComponentProps } from "react"; + +import { cn } from "@/lib/utils"; + +const SHOW_REFERENCE_EVENT = "workspace:show-reference"; + +export function CitationLink({ + href, + children, + className, + ...props +}: ComponentProps<"a">) { + const childrenText = normalizeCitationText(children); + const displayText = childrenText ?? ""; + const referenceId = href?.startsWith("#reference-") + ? href.slice("#reference-".length) + : null; + + if (referenceId) { + const badgeLabel = displayText.replace(/^\[|\]$/g, "") || referenceId; + return ( + { + event.preventDefault(); + const batchId = + event.currentTarget.closest("[data-reference-batch-id]") + ?.dataset.referenceBatchId; + window.dispatchEvent( + new CustomEvent(SHOW_REFERENCE_EVENT, { + detail: { batchId, referenceId }, + }), + ); + const scroll = () => { + const target = document.getElementById(`reference-${referenceId}`); + target?.scrollIntoView({ behavior: "smooth", block: "center" }); + }; + window.setTimeout(scroll, 240); + }} + {...props} + > + {badgeLabel} + + ); + } + + return ( + + {displayText || children} + + ); +} + +function normalizeCitationText(children: ComponentProps<"a">["children"]) { + if (typeof children !== "string") { + return null; + } + let text = children.trim(); + while (/^citation:\s*/i.test(text)) { + text = text.replace(/^citation:\s*/i, ""); + } + return text; +} diff --git a/frontend-web/src/components/workspace/input-box.tsx b/frontend-web/src/components/workspace/input-box.tsx index 986c5423..966268fd 100644 --- a/frontend-web/src/components/workspace/input-box.tsx +++ b/frontend-web/src/components/workspace/input-box.tsx @@ -3,6 +3,7 @@ import type { ChatStatus } from "ai"; import { BrainCircuitIcon, + BookMarkedIcon, CheckIcon, FileTextIcon, GraduationCapIcon, @@ -86,6 +87,11 @@ import { } from "../ui/dropdown-menu"; import { useRecommendedQuestions } from "@/core/recommended-questions/hooks"; +import { + isThreadRagModeLocked, + lockThreadRagMode, + useLocalSettings, +} from "@/core/settings"; import { useThread } from "./messages/context"; @@ -177,6 +183,7 @@ export function InputBox({ }; }) { const { t } = useI18n(); + const [localSettings, setLocalSettings] = useLocalSettings(); const searchParams = useSearchParams(); const [modelDialogOpen, setModelDialogOpen] = useState(false); const { models } = useModels(); @@ -297,6 +304,15 @@ export function InputBox({ }); }, [context, memoryInjectionEnabled, onContextChange]); + const ragModeLocked = isThreadRagModeLocked(threadId); + const ragModeEnabled = localSettings.display.ragModeEnabled || ragModeLocked; + const handleRagModeToggle = useCallback(() => { + if (ragModeLocked) { + return; + } + setLocalSettings("display", { ragModeEnabled: !ragModeEnabled }); + }, [ragModeEnabled, ragModeLocked, setLocalSettings]); + const handleSubmit = useCallback( async (message: PromptInputMessage) => { if (status === "streaming") { @@ -308,9 +324,12 @@ export function InputBox({ } setFollowups([]); setFollowupsHidden(false); - setFollowupsLoading(false); - - // Guard against submitting before the initial model auto-selection + setFollowupsLoading(false); + if (ragModeEnabled) { + lockThreadRagMode(threadId); + } + + // Guard against submitting before the initial model auto-selection // effect has flushed thread settings to storage/state. if (resolvedModelName && context.model_name !== resolvedModelName) { onContextChange?.({ @@ -332,11 +351,13 @@ export function InputBox({ onContextChange, onSubmit, onStop, - resolvedModelName, - selectedModel?.supports_thinking, - status, - ], - ); + resolvedModelName, + ragModeEnabled, + selectedModel?.supports_thinking, + status, + threadId, + ], + ); const requestFormSubmit = useCallback(() => { const form = promptRootRef.current?.querySelector("form"); @@ -622,6 +643,33 @@ export function InputBox({ 记忆 + + + {ragModeEnabled && ( + + )} + + 参考文献 + + { return { - a: (props: AnchorHTMLAttributes) => { - if (typeof props.children === "string") { - const match = /^citation:(.+)$/.exec(props.children); - if (match) { + a: (props: AnchorHTMLAttributes) => { + if (props.href?.startsWith("#reference-")) { + return ; + } + if (typeof props.children === "string") { + const match = /^citation:(.+)$/.exec(props.children); + if (match) { const [, text] = match; return {text}; } diff --git a/frontend-web/src/components/workspace/messages/message-group.tsx b/frontend-web/src/components/workspace/messages/message-group.tsx index 40f5348d..d354373d 100644 --- a/frontend-web/src/components/workspace/messages/message-group.tsx +++ b/frontend-web/src/components/workspace/messages/message-group.tsx @@ -47,6 +47,17 @@ import { findToolCallResult, } from "@/core/messages/utils"; import { useRehypeSplitWordsIntoSpans } from "@/core/rehype"; +import { useLocalSettings } from "@/core/settings"; +import { useSkills } from "@/core/skills/hooks"; +import { + buildSkillDisplayMap, + findDisplayConfigForTool, + isDisplaySamplePayload, + normalizeDefaultDisplaySources, + shouldUseDefaultCitationMode, + type RuntimeDisplaySource, +} from "@/core/skill-display/runtime"; +import { normalizeDisplaySources } from "@/core/skill-display/display-adapter"; import { extractTitleFromMarkdown } from "@/core/utils/markdown"; import { env } from "@/env"; import { cn } from "@/lib/utils"; @@ -54,6 +65,7 @@ import { cn } from "@/lib/utils"; import { useArtifactsOptional } from "../artifacts"; import { FlipDisplay } from "../flip-display"; import { Tooltip } from "../tooltip"; +import { SourceCard } from "../skill-display-sources"; import { MarkdownContent } from "./markdown-content"; @@ -74,6 +86,12 @@ export function MessageGroup({ env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true", ); const steps = useMemo(() => convertToSteps(messages), [messages]); + const [settings] = useLocalSettings(); + const { skills } = useSkills(); + const skillDisplayMap = useMemo( + () => (settings.display.ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()), + [settings.display.ragModeEnabled, skills], + ); const lastToolCallStep = useMemo(() => { const filteredSteps = steps.filter((step) => step.type === "toolCall"); return filteredSteps[filteredSteps.length - 1]; @@ -142,7 +160,13 @@ export function MessageGroup({ } > ) : ( - + ), )} {lastToolCallStep && ( @@ -152,6 +176,8 @@ export function MessageGroup({ {...lastToolCallStep} isLast={true} isLoading={isLoading} + skillDisplayMap={skillDisplayMap} + ragModeEnabled={settings.display.ragModeEnabled} /> )} @@ -209,6 +235,8 @@ function ToolCall({ result, isLast = false, isLoading = false, + skillDisplayMap = new Map(), + ragModeEnabled = false, }: { id?: string; messageId?: string; @@ -217,11 +245,56 @@ function ToolCall({ result?: string | Record; isLast?: boolean; isLoading?: boolean; + skillDisplayMap?: ReturnType; + ragModeEnabled?: boolean; }) { const { t } = useI18n(); const artifacts = useArtifactsOptional(); + const displayMatch = + result !== undefined && isDisplaySamplePayload(result) + ? null + : (findDisplayConfigForTool(name, skillDisplayMap, args) ?? + null); + const displayItems = + displayMatch && result !== undefined + ? normalizeDisplaySources(result, displayMatch.display).map((item, index) => ({ + ...item, + index: index + 1, + skillName: displayMatch.skillName, + mode: displayMatch.display.mode, + })) + : []; + if (displayItems.length > 0) { + return ( + + + + ); + } + const searchItems = normalizeSearchResults(result); + if (ragModeEnabled && shouldUseDefaultCitationMode(name, result, args) && searchItems.length > 0) { + const defaultItems = normalizeDefaultDisplaySources(result).map((item, index) => ({ + ...item, + index: index + 1, + skillName: name, + mode: "citation" as const, + })); + return ( + + + + ); + } if (name === "web_search" || searchItems.length > 0) { const query = resolveSearchQuery(args, result); let label: React.ReactNode = t.toolCalls.searchForRelatedInfo; @@ -544,6 +617,28 @@ function WebSearchResultsList({ results }: { results: SearchResultItem[] }) { ); } +function DisplayResultsList({ results }: { results: RuntimeDisplaySource[] }) { + const [open, setOpen] = useState(false); + return ( + + + + {results.length} 条真实返回结果 + + + {results.map((item) => ( + + ))} + + + ); +} + function SearchResultCard({ item }: { item: SearchResultItem }) { const [open, setOpen] = useState(false); const content = item.snippet ?? item.content; diff --git a/frontend-web/src/components/workspace/messages/message-list-item.tsx b/frontend-web/src/components/workspace/messages/message-list-item.tsx index d61f8a90..950ed302 100644 --- a/frontend-web/src/components/workspace/messages/message-list-item.tsx +++ b/frontend-web/src/components/workspace/messages/message-list-item.tsx @@ -36,15 +36,20 @@ import { type FileInMessage, } from "@/core/messages/utils"; import { useRehypeSplitWordsIntoSpans } from "@/core/rehype"; +import { + applyInlineCitations, + type RuntimeDisplaySource, +} from "@/core/skill-display/runtime"; import { humanMessagePlugins } from "@/core/streamdown"; import { desensitize } from "@/lib/desensitize"; 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 } from "./markdown-content"; +import { MarkdownContent, type MarkdownContentProps } from "./markdown-content"; import { MessageTokenUsage } from "./message-token-usage"; /** @@ -76,6 +81,7 @@ export function MessageListItem({ isLoading, threadId, tokenUsageEnabled = false, + displaySources = [], extraRehypePlugins, }: { className?: string; @@ -83,6 +89,7 @@ export function MessageListItem({ isLoading?: boolean; threadId: string; tokenUsageEnabled?: boolean; + displaySources?: RuntimeDisplaySource[]; extraRehypePlugins?: ReturnType; }) { const isHuman = message.type === "human"; @@ -101,6 +108,7 @@ export function MessageListItem({ isLoading={isLoading} threadId={threadId} tokenUsageEnabled={tokenUsageEnabled} + displaySources={displaySources} extraRehypePlugins={extraRehypePlugins} /> {!isLoading && ( @@ -162,6 +170,7 @@ function MessageContent_({ isLoading = false, threadId, tokenUsageEnabled = false, + displaySources = [], extraRehypePlugins, }: { className?: string; @@ -169,6 +178,7 @@ function MessageContent_({ isLoading?: boolean; threadId: string; tokenUsageEnabled?: boolean; + displaySources?: RuntimeDisplaySource[]; extraRehypePlugins?: ReturnType; }) { const baseRehypePlugins = useRehypeSplitWordsIntoSpans(isLoading); @@ -184,6 +194,12 @@ function MessageContent_({ ), a: ({ href, ...props }: AnchorHTMLAttributes) => { + if (href?.startsWith("#reference-")) { + return ; + } + if (typeof props.children === "string" && /^citation:/i.test(props.children)) { + return ; + } if (href?.startsWith("/mnt/")) { const url = resolveArtifact(href); return ( @@ -220,8 +236,8 @@ function MessageContent_({ if (isHuman) { return rawContent ? stripArtifactTag(stripUploadedFilesTag(rawContent)) : ""; } - return rawContent ?? ""; - }, [rawContent, isHuman]); + return applyInlineCitations(rawContent ?? "", displaySources); + }, [rawContent, isHuman, displaySources]); const filesList = files && files.length > 0 ? ( @@ -287,13 +303,15 @@ function MessageContent_({ return ( {filesList} - +
+ +
splitSkillDisplayPlaceholders(content, displaySources), + [content, displaySources], + ); + const fallbackBlocks = useMemo(() => { + return groupDisplaySources(displaySources).filter( + (group) => group.mode !== "citation" && !placedKeys.has(group.key), + ); + }, [displaySources, placedKeys]); + + return ( + <> + {parts.map((part, index) => + part.type === "markdown" ? ( + + ) : ( + + ), + )} + {fallbackBlocks.map((group) => ( + + ))} + + ); +} + +type DisplayPart = + | { type: "markdown"; content: string } + | { + type: "display"; + key: string; + mode: RuntimeDisplaySource["mode"]; + sources: RuntimeDisplaySource[]; + }; + +function splitSkillDisplayPlaceholders( + content: string, + displaySources: RuntimeDisplaySource[], +): { parts: DisplayPart[]; placedKeys: Set } { + const pattern = /]*?)\/>/g; + const parts: DisplayPart[] = []; + const placedKeys = new Set(); + let cursor = 0; + for (const match of content.matchAll(pattern)) { + const start = match.index ?? 0; + const before = content.slice(cursor, start); + if (before.trim()) { + parts.push({ type: "markdown", content: before }); + } + const attrs = parseDisplayAttrs(match[1] ?? ""); + const group = findDisplayGroup(displaySources, attrs.tool, attrs.mode); + if (group) { + parts.push({ + type: "display", + key: group.key, + mode: group.mode, + sources: group.sources, + }); + placedKeys.add(group.key); + } + cursor = start + match[0].length; + } + const rest = content.slice(cursor); + if (rest.trim() || parts.length === 0) { + parts.push({ type: "markdown", content: rest }); + } + return { parts, placedKeys }; +} + +function parseDisplayAttrs(raw: string): { tool?: string; mode?: RuntimeDisplaySource["mode"] } { + const attrs: Record = {}; + for (const match of raw.matchAll(/([a-zA-Z][\w-]*)="([^"]*)"/g)) { + attrs[match[1]!] = match[2]!; + } + return { + tool: attrs.tool || attrs.skill || attrs.name, + mode: attrs.mode as RuntimeDisplaySource["mode"] | undefined, + }; +} + +function findDisplayGroup( + sources: RuntimeDisplaySource[], + tool?: string, + mode?: RuntimeDisplaySource["mode"], +) { + return groupDisplaySources(sources).find((group) => { + if (mode && group.mode !== mode) { + return false; + } + if (!tool) { + return true; + } + const left = normalizeName(group.skillName); + const right = normalizeName(tool); + return left === right || left.includes(right) || right.includes(left); + }); +} + +function groupDisplaySources(sources: RuntimeDisplaySource[]) { + const groups = new Map< + string, + { + key: string; + skillName: string; + mode: RuntimeDisplaySource["mode"]; + sources: RuntimeDisplaySource[]; + } + >(); + for (const source of sources) { + const key = `${source.skillName}:${source.mode}`; + const group = + groups.get(key) ?? + { + key, + skillName: source.skillName, + mode: source.mode, + sources: [], + }; + group.sources.push(source); + groups.set(key, group); + } + return [...groups.values()]; +} + +function normalizeName(name: string) { + return name.toLowerCase().replace(/[^a-z0-9]+/g, ""); +} diff --git a/frontend-web/src/components/workspace/messages/message-list.tsx b/frontend-web/src/components/workspace/messages/message-list.tsx index ce10ffb2..7b0a9a7a 100644 --- a/frontend-web/src/components/workspace/messages/message-list.tsx +++ b/frontend-web/src/components/workspace/messages/message-list.tsx @@ -1,3 +1,4 @@ +import type { Message } from "@langchain/langgraph-sdk"; import type { BaseStream } from "@langchain/langgraph-sdk/react"; import { CheckIcon, SendIcon, XIcon } from "lucide-react"; import { useCallback, useMemo, useState } from "react"; @@ -20,6 +21,12 @@ import { hasToolCalls, } from "@/core/messages/utils"; import { useRehypeSplitWordsIntoSpans } from "@/core/rehype"; +import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings"; +import { useSkills } from "@/core/skills/hooks"; +import { + buildSkillDisplayMap, + collectRuntimeDisplaySources, +} from "@/core/skill-display/runtime"; import type { Subtask } from "@/core/tasks"; import { useUpdateSubtask } from "@/core/tasks/context"; import type { AgentThreadState } from "@/core/threads"; @@ -63,7 +70,6 @@ export function MessageList({ onSetupAgentApprove?: (payload: SetupAgentApprovalPayload) => void | Promise; onSetupAgentReject?: (payload: SetupAgentApprovalPayload) => void | Promise; }) { - const { t } = useI18n(); const baseRehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading); const rehypePlugins = useMemo( () => (extraRehypePlugins?.length ? [...baseRehypePlugins, ...extraRehypePlugins] : baseRehypePlugins), @@ -71,6 +77,14 @@ export function MessageList({ ); const updateSubtask = useUpdateSubtask(); const messages = thread.messages; + const [settings] = useLocalSettings(); + const ragModeEnabled = + settings.display.ragModeEnabled || isThreadRagModeLocked(threadId); + const { skills } = useSkills(); + const skillDisplayMap = useMemo( + () => (ragModeEnabled ? buildSkillDisplayMap(skills) : new Map()), + [ragModeEnabled, skills], + ); // Track which clarification messages are still awaiting an answer. // Pending = clarification tool messages that appear after the last human message @@ -190,6 +204,16 @@ export function MessageList({ {groupMessages(messages, (group) => { if (group.type === "human" || group.type === "assistant") { return group.messages.map((msg) => { + const displaySources = + ragModeEnabled && + group.type === "assistant" && + msg.type === "ai" + ? collectRuntimeDisplaySources(messages, skillDisplayMap, { + afterMessageId: findPreviousHumanMessageId(messages, msg.id), + beforeMessageId: msg.id, + includeBeforeMessage: true, + }) + : []; return ( ); @@ -442,6 +467,24 @@ export type SetupAgentApprovalPayload = { skills?: string[]; }; +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 undefined; +} + function getSetupAgentApprovalPayload( message: unknown, ): SetupAgentApprovalPayload | null { 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 77c548a1..5970d17e 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,17 @@ 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, + useCitationDisplaySettings, + useUpdateAppearanceSettings, + useUpdateCitationDisplaySettings, +} from "@/core/system-settings/hooks"; import { cn } from "@/lib/utils"; import { SettingsSection } from "./settings-section"; @@ -31,6 +41,28 @@ 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 { data: citationDisplay } = useCitationDisplaySettings(); + const updateCitationDisplay = useUpdateCitationDisplaySettings(); + const globalDefault = appearance?.default_theme ?? "light"; + const cleanupWords = + citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? []; + + 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 +108,43 @@ export function AppearanceSettingsPage() {
+ {isAdmin && ( + <> + + +
+ + +

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

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

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

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

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

+
+