diff --git a/frontend-web/src/components/page-sdebar.css b/frontend-web/src/components/page-sdebar.css index 769379b1..06ad158e 100644 --- a/frontend-web/src/components/page-sdebar.css +++ b/frontend-web/src/components/page-sdebar.css @@ -1,27 +1,42 @@ .sidebar-contents{ width:98px; - height:986px; + min-height:986px; background:rgba(3, 38, 80, 0.8); border:1px solid; border-color:rgba(43, 143, 254, 0.6); border-radius:4px; backdrop-filter:blur(2px); display: flex; - justify-content: center; + flex-direction: column; + align-items: center; + padding: 12px 10px; } .item-nav{ - width:78px; - height:70px; - background:rgba(0, 12, 28, 0.15); - border:1px solid; - border-color:rgba(85, 166, 254, 0.2); - line-height:28px; - font-family:Noto Sans S Chinese; + width:100%; + padding:14px 0; + border-bottom:1px solid rgba(85, 166, 254, 0.2); + font-family:'Noto Sans S Chinese', sans-serif; font-weight:500; color:rgba(255, 255, 255, 0.8); font-size:20px; - padding-bottom:18px; + line-height:26px; text-align:center; - margin: 10px; + cursor:pointer; + transition:background 0.2s ease, color 0.2s ease; +} + +.item-nav:hover{ + background:rgba(85, 166, 254, 0.12); + color:#ffffff; +} + +.item-nav__label{ + display:block; + width:3em; + margin:0 auto; + padding-left:6px; + box-sizing:border-box; + letter-spacing:6px; + word-break:break-all; } \ No newline at end of file diff --git a/frontend-web/src/components/page-sidebar-dark.tsx b/frontend-web/src/components/page-sidebar-dark.tsx index d67f34b1..cf24537d 100644 --- a/frontend-web/src/components/page-sidebar-dark.tsx +++ b/frontend-web/src/components/page-sidebar-dark.tsx @@ -332,9 +332,11 @@ export function PageSidebarDark({ {/* TODO: 在这里编写深色主题的侧边栏 HTML 结构 */}
-
- 问答管理 -
+ {visibleMenuItems.map((item) => ( +
+ {item.label} +
+ ))}
diff --git a/frontend-web/src/components/workspace/agents/agent-card.tsx b/frontend-web/src/components/workspace/agents/agent-card.tsx index 7f6df666..9d4d7d1a 100644 --- a/frontend-web/src/components/workspace/agents/agent-card.tsx +++ b/frontend-web/src/components/workspace/agents/agent-card.tsx @@ -9,6 +9,7 @@ import { ShieldIcon, SparklesIcon, SquarePenIcon, + StarIcon, TagsIcon, Trash2Icon, } from "lucide-react"; @@ -45,6 +46,7 @@ import { useDeleteAgent, useSetAgentFeatured, useSetAgentPrivate, + useToggleAgentFavorite, useUpdateAgent, } from "@/core/agents"; import type { Agent } from "@/core/agents"; @@ -65,6 +67,7 @@ export function AgentCard({ agent }: AgentCardProps) { const updateAgent = useUpdateAgent(); const setFeatured = useSetAgentFeatured(); const setPrivate = useSetAgentPrivate(); + const toggleFavorite = useToggleAgentFavorite(); const { skills, isLoading: isSkillsLoading } = useSkills(); const [deleteOpen, setDeleteOpen] = useState(false); const [editOpen, setEditOpen] = useState(false); @@ -99,6 +102,10 @@ export function AgentCard({ agent }: AgentCardProps) { // everyone so toggling published is moot. const canEdit = isOwned || isAdmin; const canDelete = isOwned || isAdmin; + // Favoriting collects a square agent into "我的"; pointless for one you + // already own, so only offer it on built-in / others' agents. + const canFavorite = !isOwned; + const isFavorite = !!agent.is_favorite; useEffect(() => { if (!editOpen) { @@ -217,6 +224,16 @@ export function AgentCard({ agent }: AgentCardProps) { } } + async function handleToggleFavorite() { + const next = !isFavorite; + try { + await toggleFavorite.mutateAsync({ id: agent.id, favorite: next }); + toast.success(next ? "已收藏到「我的」" : "已取消收藏"); + } 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 @@ -367,8 +384,25 @@ export function AgentCard({ agent }: AgentCardProps) { )} - {(canEdit || canDelete || isOwned || isAdmin) && ( + {(canEdit || canDelete || isOwned || isAdmin || canFavorite) && (
+ {canFavorite && ( + + )} {isAdmin && ( -
- - - - - - - - ) : selectedArtifact ? ( - - ) : hasCitationReferences ? ( -
-
- -
- -
- ) : ( -
-
- -
- {thread.values.artifacts?.length === 0 ? ( - } - title="No artifact selected" - description="Select an artifact to view its details" - /> - ) : ( -
-
-

Artifacts

-
-
- -
-
- )} -
- )} - - - - ); -}; - -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 }; +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"; +import type { GroupImperativeHandle } from "react-resizable-panels"; + +import { ConversationEmptyState } from "@/components/ai-elements/conversation"; +import { Button } from "@/components/ui/button"; +import { + ResizableHandle, + 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"; + +import { + ArtifactFileDetail, + ArtifactFileList, + 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, + threadId, +}) => { + 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, + open: artifactsOpen, + setOpen: setArtifactsOpen, + setArtifacts, + select: selectArtifact, + deselect, + selectedArtifact, + } = 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, + skillDisplayMap, + ), + [backendReferenceBatches, skillDisplayMap], + ); + const referenceBatches = useMemo( + () => { + if (adaptedBackendReferenceBatches.length > 0) { + return adaptedBackendReferenceBatches; + } + return 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), + backendBatches: debugReferenceBatches(backendReferenceBatches), + adaptedBackendBatches: debugReferenceBatches(adaptedBackendReferenceBatches), + fallbackBatches: debugReferenceBatches(fallbackReferenceBatches), + finalBatches: debugReferenceBatches(referenceBatches), + }); + }, [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 (!ragModeEnabled || !activeReferenceBatch) { + return; + } + logReferenceDebug("frontend-active-reference-batch-payload", threadId, { + activeBatchId: activeReferenceBatch.id, + assistantMessageId: activeReferenceBatch.assistant_message_id, + sourceCount: activeReferenceBatch.sources.length, + sources: activeReferenceBatch.sources.map(debugReferenceSource), + }); + }, [activeReferenceBatch, ragModeEnabled, threadId]); + 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 + 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 ( + // selectedArtifact && + // !thread.values.artifacts?.includes(selectedArtifact) + // ) { + // deselect(); + // } + + if ( + env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && + autoSelectFirstArtifact + ) { + if (thread?.values?.artifacts?.length > 0) { + setAutoSelectFirstArtifact(false); + selectArtifact(thread.values.artifacts[0]!); + } + } + }, [ + threadId, + autoSelectFirstArtifact, + deselect, + selectArtifact, + selectedArtifact, + setArtifacts, + thread.values.artifacts, + ]); + + const artifactPanelOpen = useMemo(() => { + if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true") { + return artifactsOpen && artifacts?.length > 0; + } + 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 + .replace(/[^a-zA-Z0-9_-]+/g, "-") + .replace(/^-+|-+$/g, ""); + return raw.length > 0 ? raw : `thread-${threadId}`; + }, [pathname, threadId]); + + useEffect(() => { + if (layoutRef.current) { + if (sidePanelOpen) { + layoutRef.current.setLayout(OPEN_MODE); + } else { + layoutRef.current.setLayout(CLOSE_MODE); + } + } + }, [sidePanelOpen]); + + return ( + + + {children} + + + +
+ {showTabs ? ( + +
+ + 文档 + 参考文献 + + +
+ + + + + + +
+ ) : selectedArtifact ? ( + + ) : hasCitationReferences ? ( +
+
+ +
+ +
+ ) : ( +
+
+ +
+ {thread.values.artifacts?.length === 0 ? ( + } + title="No artifact selected" + description="Select an artifact to view its details" + /> + ) : ( +
+
+

Artifacts

+
+
+ +
+
+ )} +
+ )} +
+
+
+ ); +}; + +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 debugReferenceBatches(batches: BackendReferenceBatch[]) { + return batches.map((batch) => ({ + id: batch.id, + assistantMessageId: batch.assistant_message_id, + sourceCount: batch.sources?.length ?? 0, + sources: (batch.sources ?? []).map(debugReferenceSource), + })); +} + +function debugReferenceSource(source: BackendReferenceSource) { + const raw = + source.raw && typeof source.raw === "object" && !Array.isArray(source.raw) + ? (source.raw as Record) + : {}; + return { + index: source.index, + id: source.id, + skillName: source.skillName, + mode: source.mode, + title: source.title, + source: source.source, + author: source.author, + time: source.time, + url: source.url, + snippet: source.snippet, + content: source.content, + rawIdCandidates: { + recUuid: raw.recUuid, + rec_uuid: raw.rec_uuid, + uuid: raw.uuid, + docId: raw.docId, + doc_id: raw.doc_id, + id: raw.id, + url: raw.url, + }, + raw: source.raw, + }; +} + +function adaptBackendReferenceBatches( + backendBatches: BackendReferenceBatch[], + skillDisplayMap: ReturnType, +): BackendReferenceBatch[] { + if (backendBatches.length === 0) { + return backendBatches; + } + return backendBatches.map((backendBatch) => { + return { + ...backendBatch, + sources: backendBatch.sources.map((backendSource) => { + const configuredSource = 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 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 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/custom-prompt-menu.tsx b/frontend-web/src/components/workspace/custom-prompt-menu.tsx new file mode 100644 index 00000000..e7d88948 --- /dev/null +++ b/frontend-web/src/components/workspace/custom-prompt-menu.tsx @@ -0,0 +1,294 @@ +"use client"; + +import { + ChevronLeftIcon, + ChevronRightIcon, + PencilIcon, + Settings2Icon, + Trash2Icon, +} from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { + useCreateCustomPrompt, + useCustomPrompts, + useDeleteCustomPrompt, + useUpdateCustomPrompt, +} from "@/core/custom-prompts"; +import { cn } from "@/lib/utils"; + +/** + * Bar above the chat input showing the user's saved prompts as centered, + * clickable chips. When there are too many to fit, they become a horizontal + * carousel (arrows appear). A gear pinned to the far right opens the manage + * dialog. Prompts are bound to the account (server-backed), and this coexists + * with the admin-configured global prompt prefix. + */ +export function CustomPromptBar({ + onInsert, + className, +}: { + onInsert: (content: string) => void; + className?: string; +}) { + const prompts = useCustomPrompts(); + const [manageOpen, setManageOpen] = useState(false); + + const scrollRef = useRef(null); + const [canLeft, setCanLeft] = useState(false); + const [canRight, setCanRight] = useState(false); + + const updateArrows = useCallback(() => { + const el = scrollRef.current; + if (!el) return; + setCanLeft(el.scrollLeft > 4); + setCanRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 4); + }, []); + + useEffect(() => { + updateArrows(); + window.addEventListener("resize", updateArrows); + return () => window.removeEventListener("resize", updateArrows); + }, [updateArrows, prompts]); + + const scrollByDir = (dir: 1 | -1) => { + scrollRef.current?.scrollBy({ left: dir * 220, behavior: "smooth" }); + }; + + const overflowing = canLeft || canRight; + + return ( + <> +
+ {prompts.length > 0 && ( + <> + {overflowing && ( + + )} +
+
+ {prompts.map((prompt) => ( + + ))} +
+
+ {overflowing && ( + + )} + + )} + {prompts.length === 0 &&
} + +
+ + + + ); +} + +function ManagePromptsDialog({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const prompts = useCustomPrompts(); + const createPrompt = useCreateCustomPrompt(); + const updatePrompt = useUpdateCustomPrompt(); + const deletePrompt = useDeleteCustomPrompt(); + const [editingId, setEditingId] = useState(null); + const [title, setTitle] = useState(""); + const [content, setContent] = useState(""); + + const saving = createPrompt.isPending || updatePrompt.isPending; + + const resetForm = () => { + setEditingId(null); + setTitle(""); + setContent(""); + }; + + const handleSubmit = async () => { + const trimmedTitle = title.trim(); + if (!content.trim()) { + toast.error("提示词内容不能为空"); + return; + } + try { + if (editingId) { + await updatePrompt.mutateAsync({ + id: editingId, + input: { title: trimmedTitle, content }, + }); + toast.success("提示词已更新"); + } else { + await createPrompt.mutateAsync({ title: trimmedTitle, content }); + toast.success("提示词已添加"); + } + resetForm(); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } + }; + + const handleEdit = (id: string) => { + const target = prompts.find((p) => p.id === id); + if (!target) return; + setEditingId(id); + setTitle(target.title); + setContent(target.content); + }; + + const handleDelete = async (id: string) => { + try { + await deletePrompt.mutateAsync(id); + if (editingId === id) resetForm(); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } + }; + + return ( + { + if (!next) resetForm(); + onOpenChange(next); + }} + > + + + 管理常用提示词 + + 保存常用的提示词,保存后会以小标签形式显示在输入框上方,点击即可填入。提示词与你的账号绑定,可跨设备使用。 + + + +
+ setTitle(e.target.value)} + placeholder="名称(可选,例如:周报总结)" + /> +