diff --git a/.gitignore b/.gitignore index 7ae1b28c..1c2eca88 100644 --- a/.gitignore +++ b/.gitignore @@ -87,24 +87,35 @@ offline-backend-20260512/backend/skill_evolution.json skills/ hermes-agent-main/ .gitignore -offline-backend-20260512/backend/tests/* -!offline-backend-20260512/backend/tests/test_llmwiki_weknora.py -!offline-backend-20260512/backend/tests/test_health_auth_bypass.py -# Keep the model configuration regression coverage versioned even though -# this deployment repository otherwise ignores newly added backend tests. -!offline-backend-20260512/backend/tests/test_model_management.py -# Deep-research reliability regression coverage. -!offline-backend-20260512/backend/tests/test_deep_research_persistence.py -!offline-backend-20260512/backend/tests/test_deep_research_event_sink.py +offline-backend-20260512/backend/tests/* +!offline-backend-20260512/backend/tests/test_llmwiki_weknora.py +!offline-backend-20260512/backend/tests/test_health_auth_bypass.py +# Keep the model configuration regression coverage versioned even though +# this deployment repository otherwise ignores newly added backend tests. +!offline-backend-20260512/backend/tests/test_model_management.py +# Keep fixed-question exact-match and streaming regression coverage versioned. +!offline-backend-20260512/backend/tests/test_fixed_questions.py +# Keep configurable es_query system-prompt routing regression coverage versioned. +!offline-backend-20260512/backend/tests/test_es_query_routing_prompt.py +# Keep the local LLMWiki vector-index and external API regression coverage versioned. +!offline-backend-20260512/backend/tests/test_llmwiki_local_index_repository.py +!offline-backend-20260512/backend/tests/test_llmwiki_vector_search.py +!offline-backend-20260512/backend/tests/test_llmwiki_wiki_sync.py +!offline-backend-20260512/backend/tests/test_external_llmwiki_search.py +!offline-backend-20260512/backend/tests/test_llmwiki_wiki_retrieval.py +!offline-backend-20260512/backend/tests/test_public_llmwiki_embed.py +# Deep-research reliability regression coverage. +!offline-backend-20260512/backend/tests/test_deep_research_persistence.py +!offline-backend-20260512/backend/tests/test_deep_research_event_sink.py !offline-backend-20260512/backend/tests/test_deep_research_executor_reliability.py !offline-backend-20260512/backend/tests/test_deep_research_start_job.py !offline-backend-20260512/backend/tests/test_summarization_human_message.py !offline-backend-20260512/backend/tests/test_compact_menu_overrides.py # Independent enterprise-research persistence, HTTP and race regressions. !offline-backend-20260512/backend/tests/test_enterprise_research_tasks.py -# Position-roundtable per-turn write/present cap (weak-model duplicate artifacts). -!offline-backend-20260512/backend/tests/test_position_artifact_cap.py -deploy +# Position-roundtable per-turn write/present cap (weak-model duplicate artifacts). +!offline-backend-20260512/backend/tests/test_position_artifact_cap.py +deploy .history # Cloned third-party repos (local only) diff --git a/frontend-web/src/App.tsx b/frontend-web/src/App.tsx index 2833dea3..7cdb7fa7 100644 --- a/frontend-web/src/App.tsx +++ b/frontend-web/src/App.tsx @@ -38,6 +38,7 @@ import EmbedChatPage from "./pages/EmbedChatPage"; import EmbedRoundtablePage from "./pages/EmbedRoundtablePage"; import DashboardStandalonePage from "@/roundtable-planning/pages/DashboardStandalonePage"; import PositionRoundtablePage from "@/position-roundtable/pages/PositionRoundtablePage"; +import WeKnoraWikiPageView from "@/strategy-components/components/resource-management/llmwiki/WeKnoraWikiPageView"; import { isPositionRoundtableStandaloneMode, POSITION_ROUNDTABLE_ROUTE, @@ -45,7 +46,12 @@ import { import { ChatRuntime } from "./pages/WorkspaceRoutes"; import { NotFoundPage } from "./pages/NotFoundPage"; -const PUBLIC_EMBED_PREFIXES = ["/share/", "/public/", "/roundtable-share/"]; +const PUBLIC_EMBED_PREFIXES = [ + "/share/", + "/public/", + "/roundtable-share/", + "/embed/knowledge/", +]; function AppProviders({ children }: { children: React.ReactNode }) { const locale = useMemo(() => detectLocale(), []); @@ -102,16 +108,17 @@ function AppProviders({ children }: { children: React.ReactNode }) { forcedTheme={forcedTheme} > - - - - {!positionStandaloneMode && } - {!positionStandaloneMode && } - - {children} - - - + + + + + {!positionStandaloneMode && } + + {children} + + + + ); @@ -188,6 +195,10 @@ export function App() { } /> } /> + } + /> } diff --git a/frontend-web/src/components/page-sidebar.tsx b/frontend-web/src/components/page-sidebar.tsx index 5119a2ba..dd85aeeb 100644 --- a/frontend-web/src/components/page-sidebar.tsx +++ b/frontend-web/src/components/page-sidebar.tsx @@ -10,6 +10,7 @@ import { FolderOpen, Gauge, ListTree, + MessageSquareText, MousePointerClick, BriefcaseBusiness, Settings, @@ -237,6 +238,14 @@ function SecondaryNavSidebar({ headerHidden }: { headerHidden: boolean }) { 按钮管理 )} + {isAdmin && ( + navigate("/page/strategy/admin/fixed-questions")} + > + + 问题配置 + + )} {isAdmin && ( navigate("/page/strategy/admin/position-roles")} @@ -558,6 +567,12 @@ function LegacyAccordionSidebar({ 按钮管理 )} + {isAdmin && ( + navigate("/page/strategy/admin/fixed-questions")}> + + 问题配置 + + )} {isAdmin && ( navigate("/page/strategy/admin/position-roles")}> diff --git a/frontend-web/src/components/workspace/artifacts/artifact-directory.tsx b/frontend-web/src/components/workspace/artifacts/artifact-directory.tsx new file mode 100644 index 00000000..8956d653 --- /dev/null +++ b/frontend-web/src/components/workspace/artifacts/artifact-directory.tsx @@ -0,0 +1,226 @@ +import { + ChevronDownIcon, + ChevronRightIcon, + FolderIcon, + FolderOpenIcon, + RefreshCwIcon, +} from "lucide-react"; +import { useMemo, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { artifactPathname, artifactRelativePath } from "@/core/artifacts/listing"; +import { getFileIcon, getFileName } from "@/core/utils/files"; +import { cn } from "@/lib/utils"; + +type DirectoryNode = { + name: string; + path: string; + directories: DirectoryNode[]; + files: Array<{ artifact: string; name: string; relativePath: string }>; +}; + +function buildDirectoryTree(artifacts: string[]): DirectoryNode { + const root: DirectoryNode = { + name: "生成文件", + path: "", + directories: [], + files: [], + }; + + for (const artifact of artifacts) { + const relativePath = artifactRelativePath(artifact); + const parts = relativePath.split("/").filter(Boolean); + if (parts.length === 0) continue; + + let current = root; + for (const directoryName of parts.slice(0, -1)) { + let child = current.directories.find((node) => node.name === directoryName); + if (!child) { + child = { + name: directoryName, + path: current.path ? `${current.path}/${directoryName}` : directoryName, + directories: [], + files: [], + }; + current.directories.push(child); + } + current = child; + } + + current.files.push({ + artifact, + name: parts.at(-1) ?? getFileName(artifact), + relativePath, + }); + } + + const sort = (node: DirectoryNode) => { + node.directories.sort((left, right) => + left.name.localeCompare(right.name, "zh-CN"), + ); + node.files.sort((left, right) => + left.name.localeCompare(right.name, "zh-CN"), + ); + node.directories.forEach(sort); + }; + sort(root); + return root; +} + +function DirectoryBranch({ + node, + depth, + collapsedDirectories, + onToggleDirectory, + selectedArtifact, + onSelect, +}: { + node: DirectoryNode; + depth: number; + collapsedDirectories: Set; + onToggleDirectory: (path: string) => void; + selectedArtifact: string | null; + onSelect: (artifact: string) => void; +}) { + return ( + <> + {node.directories.map((directory) => { + const collapsed = collapsedDirectories.has(directory.path); + return ( +
+ + {!collapsed && ( + + )} +
+ ); + })} + {node.files.map((file) => { + const selected = + !!selectedArtifact && + artifactPathname(selectedArtifact) === artifactPathname(file.artifact); + return ( + + ); + })} + + ); +} + +export function ArtifactDirectory({ + className, + artifacts, + selectedArtifact, + onSelect, + onRefresh, + refreshing = false, +}: { + className?: string; + artifacts: string[]; + selectedArtifact: string | null; + onSelect: (artifact: string) => void; + onRefresh?: () => void; + refreshing?: boolean; +}) { + const tree = useMemo(() => buildDirectoryTree(artifacts), [artifacts]); + const [collapsedDirectories, setCollapsedDirectories] = useState>( + () => new Set(), + ); + + const toggleDirectory = (path: string) => { + setCollapsedDirectories((current) => { + const next = new Set(current); + if (next.has(path)) next.delete(path); + else next.add(path); + return next; + }); + }; + + return ( + + ); +} diff --git a/frontend-web/src/components/workspace/artifacts/index.ts b/frontend-web/src/components/workspace/artifacts/index.ts index 06c6bc71..270d9e68 100644 --- a/frontend-web/src/components/workspace/artifacts/index.ts +++ b/frontend-web/src/components/workspace/artifacts/index.ts @@ -1,4 +1,5 @@ -export * from "./artifact-file-detail"; -export * from "./artifact-file-list"; +export * from "./artifact-file-detail"; +export * from "./artifact-directory"; +export * from "./artifact-file-list"; export * from "./artifact-trigger"; export * from "./context"; diff --git a/frontend-web/src/components/workspace/chats/chat-box.tsx b/frontend-web/src/components/workspace/chats/chat-box.tsx index fa812476..e2a6c51a 100644 --- a/frontend-web/src/components/workspace/chats/chat-box.tsx +++ b/frontend-web/src/components/workspace/chats/chat-box.tsx @@ -1,4 +1,5 @@ import type { Message } from "@langchain/langgraph-sdk"; +import { useQuery } from "@tanstack/react-query"; import { FilesIcon, XIcon } from "lucide-react"; import { usePathname } from "next/navigation"; import { useEffect, useMemo, useRef, useState } from "react"; @@ -13,6 +14,10 @@ import { } from "@/components/ui/resizable"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { apiFetch } from "@/core/api"; +import { + listThreadArtifacts, + mergeArtifactPaths, +} from "@/core/artifacts/listing"; import { getBackendBaseURL } from "@/core/config"; import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings"; import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag"; @@ -32,6 +37,7 @@ import { cn } from "@/lib/utils"; import { ArtifactFileDetail, + ArtifactDirectory, ArtifactFileList, useArtifacts, } from "../artifacts"; @@ -40,6 +46,7 @@ import { SkillDisplaySources } from "../skill-display-sources"; const CLOSE_MODE = { chat: 100, artifacts: 0 }; const OPEN_MODE = { chat: 60, artifacts: 40 }; +const DIRECTORY_OPEN_MODE = { chat: 48, artifacts: 52 }; const SHOW_REFERENCE_EVENT = "workspace:show-reference"; type BackendReferenceSource = Parameters[0]["sources"][number]; @@ -78,7 +85,7 @@ const ChatBox: React.FC<{ threadId, disableArtifacts = false, }) => { - const { thread } = useThread(); + const { isMock, thread } = useThread(); const pathname = usePathname(); const threadIdRef = useRef(threadId); const artifactsCountRef = useRef(0); @@ -92,6 +99,8 @@ const ChatBox: React.FC<{ const [pinnedReferenceId, setPinnedReferenceId] = useState(null); const [backendReferenceBatches, setBackendReferenceBatches] = useState([]); const [settings] = useLocalSettings(); + const artifactDirectoryModeEnabled = + settings.display.artifactDirectoryModeEnabled ?? false; const ragModeEnabled = settings.display.ragModeEnabled || isThreadRagModeLocked(threadId); // Frontend RAG modes write their own hidden source tool messages. They use @@ -110,6 +119,52 @@ const ChatBox: React.FC<{ referencesEnabled && !hasFrontendRag; const { skills } = useSkills(); + const canScanArtifactDirectory = + artifactDirectoryModeEnabled && + !disableArtifacts && + !isMock && + !!threadId && + threadId !== "new"; + const artifactListingQuery = useQuery({ + queryKey: ["artifact-directory", threadId], + queryFn: ({ signal }) => listThreadArtifacts(threadId, signal), + enabled: canScanArtifactDirectory, + refetchInterval: thread.isLoading ? 1_500 : false, + retry: false, + }); + const checkpointArtifacts = useMemo( + () => (disableArtifacts ? [] : (thread.values.artifacts ?? [])), + [disableArtifacts, thread.values.artifacts], + ); + const checkpointArtifactSignature = checkpointArtifacts.join("\n"); + const availableArtifacts = useMemo( + () => + artifactDirectoryModeEnabled + ? mergeArtifactPaths( + checkpointArtifacts, + (artifactListingQuery.data?.files ?? []).map((file) => file.path), + ) + : checkpointArtifacts, + [ + artifactDirectoryModeEnabled, + artifactListingQuery.data?.files, + checkpointArtifacts, + ], + ); + + useEffect(() => { + if (!canScanArtifactDirectory || thread.isLoading) return; + // Run once after streaming settles as well as when checkpoint artifacts + // change, so files written without present_files are not missed by the + // last polling tick. + void artifactListingQuery.refetch(); + }, [ + artifactListingQuery.refetch, + canScanArtifactDirectory, + checkpointArtifactSignature, + thread.isLoading, + ]); + const { artifacts, open: artifactsOpen, @@ -302,7 +357,8 @@ const ChatBox: React.FC<{ }; }, []); useEffect(() => { - if (threadIdRef.current !== threadId) { + const threadChanged = threadIdRef.current !== threadId; + if (threadChanged) { threadIdRef.current = threadId; artifactsCountRef.current = 0; deselect(); @@ -316,15 +372,21 @@ const ChatBox: React.FC<{ } // Update artifacts from the current thread - const nextArtifacts = disableArtifacts ? [] : (thread.values.artifacts ?? []); + const nextArtifacts = availableArtifacts; const hadArtifacts = artifactsCountRef.current > 0; const hasNewArtifact = nextArtifacts.length > artifactsCountRef.current; artifactsCountRef.current = nextArtifacts.length; - setArtifacts(nextArtifacts); + const artifactsChanged = + artifacts.length !== nextArtifacts.length || + artifacts.some((artifact, index) => artifact !== nextArtifacts[index]); + if (artifactsChanged) { + setArtifacts(nextArtifacts); + } if ( !disableArtifacts && - (hasNewArtifact || (!hadArtifacts && nextArtifacts.length > 0)) && - !selectedArtifact + nextArtifacts.length > 0 && + ((artifactDirectoryModeEnabled && threadChanged) || + ((hasNewArtifact || !hadArtifacts) && !selectedArtifact)) ) { selectArtifact(nextArtifacts[nextArtifacts.length - 1]!); setArtifactsOpen(true); @@ -351,15 +413,41 @@ const ChatBox: React.FC<{ } }, [ threadId, + artifactDirectoryModeEnabled, autoSelectFirstArtifact, deselect, selectArtifact, selectedArtifact, + artifacts, setArtifacts, - thread.values.artifacts, + availableArtifacts, disableArtifacts, ]); + const directoryModeWasEnabledRef = useRef(artifactDirectoryModeEnabled); + useEffect(() => { + const wasEnabled = directoryModeWasEnabledRef.current; + directoryModeWasEnabledRef.current = artifactDirectoryModeEnabled; + if ( + wasEnabled || + !artifactDirectoryModeEnabled || + availableArtifacts.length === 0 + ) { + return; + } + if (!selectedArtifact) { + selectArtifact(availableArtifacts[availableArtifacts.length - 1]!); + } + setArtifactsOpen(true); + setSideTab("document"); + }, [ + artifactDirectoryModeEnabled, + availableArtifacts, + selectArtifact, + selectedArtifact, + setArtifactsOpen, + ]); + const artifactPanelOpen = useMemo(() => { if (disableArtifacts) { return false; @@ -370,7 +458,10 @@ const ChatBox: React.FC<{ return artifactsOpen; }, [artifactsOpen, artifacts, disableArtifacts]); const sidePanelOpen = artifactPanelOpen || (hasCitationReferences && referencesOpen); - const showTabs = !!selectedArtifact && hasCitationReferences; + const showTabs = + hasCitationReferences && + (!!selectedArtifact || + (artifactDirectoryModeEnabled && artifacts.length > 0)); useEffect(() => { if (selectedArtifact) { @@ -390,12 +481,103 @@ const ChatBox: React.FC<{ useEffect(() => { if (layoutRef.current) { if (sidePanelOpen) { - layoutRef.current.setLayout(OPEN_MODE); + layoutRef.current.setLayout( + artifactDirectoryModeEnabled && artifactPanelOpen + ? DIRECTORY_OPEN_MODE + : OPEN_MODE, + ); } else { layoutRef.current.setLayout(CLOSE_MODE); } } - }, [sidePanelOpen]); + }, [artifactDirectoryModeEnabled, artifactPanelOpen, sidePanelOpen]); + + const artifactDocumentPanel = artifactDirectoryModeEnabled ? ( +
+ { + selectArtifact(artifact); + setArtifactsOpen(true); + }} + onRefresh={ + canScanArtifactDirectory + ? () => void artifactListingQuery.refetch() + : undefined + } + refreshing={artifactListingQuery.isFetching} + /> +
+ {selectedArtifact ? ( + + ) : ( + <> +
+ +
+ } + title="请选择文件" + description="从左侧目录中选择一个文件进行查看" + /> + + )} +
+
+ ) : selectedArtifact ? ( + + ) : ( +
+
+ +
+ {artifacts.length === 0 ? ( + } + title="No artifact selected" + description="Select an artifact to view its details" + /> + ) : ( +
+
+

Artifacts

+
+
+ +
+
+ )} +
+ ); if (disableArtifacts) { return ( @@ -455,13 +637,7 @@ const ChatBox: React.FC<{ - + {artifactDocumentPanel} - ) : selectedArtifact ? ( - + ) : artifactPanelOpen ? ( + artifactDocumentPanel ) : hasCitationReferences ? (
@@ -497,41 +667,7 @@ const ChatBox: React.FC<{ sources={referenceSources} />
- ) : ( -
-
- -
- {thread.values.artifacts?.length === 0 ? ( - } - title="No artifact selected" - description="Select an artifact to view its details" - /> - ) : ( -
-
-

Artifacts

-
-
- -
-
- )} -
- )} + ) : null}
diff --git a/frontend-web/src/components/workspace/input-box.tsx b/frontend-web/src/components/workspace/input-box.tsx index 73a18a89..19cf6cb4 100644 --- a/frontend-web/src/components/workspace/input-box.tsx +++ b/frontend-web/src/components/workspace/input-box.tsx @@ -2,13 +2,14 @@ import type { ChatStatus } from "ai"; import { - BarChart3Icon, - BrainCircuitIcon, - BookMarkedIcon, - CheckIcon, - ClipboardCheckIcon, - FileTextIcon, - GraduationCapIcon, + BarChart3Icon, + BrainCircuitIcon, + BookMarkedIcon, + CheckIcon, + ClipboardCheckIcon, + FileTextIcon, + FolderTreeIcon, + GraduationCapIcon, LightbulbIcon, MessageCircleQuestionIcon, PaperclipIcon, @@ -446,12 +447,20 @@ export function InputBox({ [onContextChange, context], ); - const handleWritingModeToggle = useCallback(() => { + const handleWritingModeToggle = useCallback(() => { onContextChange?.({ ...context, writing_mode: !context.writing_mode, }); - }, [context, onContextChange]); + }, [context, onContextChange]); + + const artifactDirectoryModeEnabled = + localSettings.display.artifactDirectoryModeEnabled ?? false; + const handleArtifactDirectoryModeToggle = useCallback(() => { + setLocalSettings("display", { + artifactDirectoryModeEnabled: !artifactDirectoryModeEnabled, + }); + }, [artifactDirectoryModeEnabled, setLocalSettings]); const memoryInjectionEnabled = context.memory_injection_enabled ?? true; const handleMemoryInjectionToggle = useCallback(() => { @@ -854,7 +863,7 @@ export function InputBox({ )} /> )} - {!frontendRagActive && 写作 - - } - {!frontendRagActive && + } + {!frontendRagActive && + + {artifactDirectoryModeEnabled && ( + + )} + + 文件 + + } + {!frontendRagActive && new Set(value), [value]); const filtered = useMemo(() => { @@ -97,6 +99,7 @@ function SelectionContent({ }, [rows, search]); const groups = useMemo(() => groupRows(filtered), [filtered]); const toggle = (id: string) => { + if (readOnly) return; onChange(selected.has(id) ? value.filter((item) => item !== id) : [...value, id]); }; @@ -168,6 +171,7 @@ function SelectionContent({ toggle(item.id)} onSelect={(event) => event.preventDefault()} className={cn( @@ -183,6 +187,7 @@ function SelectionContent({ type="button" role="checkbox" aria-checked={selected.has(item.id)} + disabled={readOnly} onClick={() => toggle(item.id)} className={cn( "my-0.5 flex min-h-11 w-full items-center gap-2 rounded-md px-2 py-1.5 text-left outline-none transition-colors hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-primary", @@ -205,15 +210,21 @@ function SelectionContent({
- 已选择 {value.length} 个 -
- - -
+ + {readOnly ? `本对话已选 ${value.length} 个` : `已选择 ${value.length} 个`} + + {readOnly ? ( + 对话开始后不可更改 + ) : ( +
+ + +
+ )}
); @@ -226,6 +237,7 @@ export function LlmWikiKnowledgeSelector({ agentId, autoSelectAll = false, disabled = false, + readOnly = false, className, label = "知识空间", variant = "popover", @@ -236,6 +248,8 @@ export function LlmWikiKnowledgeSelector({ agentId?: string | null; autoSelectAll?: boolean; disabled?: boolean; + /** Keep the selected knowledge spaces visible without allowing the thread scope to change. */ + readOnly?: boolean; className?: string; label?: string; variant?: SelectorVariant; @@ -293,6 +307,7 @@ export function LlmWikiKnowledgeSelector({ error={error} source={source} variant={variant} + readOnly={readOnly} /> ); @@ -315,9 +330,14 @@ export function LlmWikiKnowledgeSelector({ ); } - const selectedName = value.length === 1 - ? rows.find((item) => item.id === value[0])?.name - : null; + const selectedNames = value + .map((id) => rows.find((item) => item.id === id)?.name) + .filter((name): name is string => !!name); + const selectedLabel = selectedNames.length > 0 + ? selectedNames.join("、") + : value.length > 0 + ? `已选 ${value.length} 个知识空间` + : label; return ( @@ -332,9 +352,10 @@ export function LlmWikiKnowledgeSelector({ className, )} disabled={disabled || loading} + title={value.length > 0 ? selectedLabel : undefined} > {loading ? : } - {selectedName || label} + {selectedLabel} {value.length > 0 && ( {value.length} diff --git a/frontend-web/src/components/workspace/messages/message-group.tsx b/frontend-web/src/components/workspace/messages/message-group.tsx index dc4e1759..564384f8 100644 --- a/frontend-web/src/components/workspace/messages/message-group.tsx +++ b/frontend-web/src/components/workspace/messages/message-group.tsx @@ -341,9 +341,9 @@ function ToolCall({ ragModeEnabled?: boolean; }) { const { t } = useI18n(); - const displayMatch = - result !== undefined && isDisplaySamplePayload(result) - ? null + const displayMatch = + result !== undefined && isDisplaySamplePayload(result) + ? null : (findDisplayConfigForTool(name, skillDisplayMap, args) ?? null); const displayItems = @@ -352,18 +352,24 @@ function ToolCall({ ...item, index: index + 1, skillName: displayMatch.skillName, - mode: item.displayMode ?? displayMatch.display.mode, - })) - : []; - if (displayItems.length > 0) { - return ( - 0) { + return ( + @@ -387,7 +393,7 @@ function ToolCall({ mode: "citation" as const, })); return ( - + ); diff --git a/frontend-web/src/components/workspace/skill-display-sources.tsx b/frontend-web/src/components/workspace/skill-display-sources.tsx index 27b012cb..f52c114d 100644 --- a/frontend-web/src/components/workspace/skill-display-sources.tsx +++ b/frontend-web/src/components/workspace/skill-display-sources.tsx @@ -1,4 +1,4 @@ -import { ExternalLinkIcon, FileTextIcon } from "lucide-react"; +import { BookOpenTextIcon, ChevronRightIcon, ExternalLinkIcon, FileTextIcon } from "lucide-react"; import { Button } from "@/components/ui/button"; import { @@ -15,9 +15,11 @@ import { } from "@/core/llmwiki/source-links"; import { useLocalSettings } from "@/core/settings"; import { useCitationDisplaySettings } from "@/core/system-settings/hooks"; +import { reasoningPlugins } from "@/core/streamdown"; import { cn } from "@/lib/utils"; import { useState } from "react"; import { WeKnoraSourceDrawer } from "@/strategy-components/components/resource-management/llmwiki/WeKnoraSourceDrawer"; +import { MarkdownContent } from "@/components/workspace/messages/markdown-content"; /** Whether a source URL is an internal WeKnora preview pointer. */ export function isWeKnoraSourcePreviewUrl(url?: string | null): boolean { @@ -71,8 +73,20 @@ export function SourceCard({ const cleanupWords = citationDisplay?.cleanup_words ?? settings.display.citationCleanupWords ?? []; const content = cleanReferenceText(source.content || source.snippet, cleanupWords); - const snippet = compactReferenceText(content).slice(0, 280); - const paragraphs = segmentReferenceText(content); + const isWikiSource = Boolean(previewTarget?.wikiSlug) || source.type === "llmwiki_wiki"; + const matchedHeading = isWikiSource + ? String( + (source.raw?.matched_heading as string | undefined) || + (source.raw?.matched_sections as Array<{ heading?: string }> | undefined)?.find( + (section) => section.heading, + )?.heading || + "", + ).trim() + : ""; + const summary = isWikiSource && typeof source.raw?.summary === "string" + ? source.raw.summary + : ""; + const snippet = plainTextReferencePreview(summary || content).slice(0, 280); const score = typeof source.score === "number" ? source.score <= 1 @@ -81,13 +95,22 @@ export function SourceCard({ : null; const meta = [ source.source, + isWikiSource ? "检索对象:Wiki 文档" : null, + matchedHeading ? `命中章节:${matchedHeading}` : null, source.author ? `作者 ${source.author}` : null, - source.type && source.type !== "result" ? source.type : null, + !isWikiSource && source.type && source.type !== "result" ? source.type : null, source.time, score ? `相关度 ${score}` : null, ].filter(Boolean); const isCitation = source.mode === "citation"; const showReferenceNumber = isCitation; + const openSource = () => { + if (previewTarget) { + setDetailOpen(true); + } else { + setRelatedOpen(true); + } + }; return ( <> @@ -102,11 +125,11 @@ export function SourceCard({ "border-amber-300 bg-amber-50/80 shadow-[0_0_0_2px_rgba(251,191,36,0.14)]", "cursor-pointer", )} - onClick={() => setRelatedOpen(true)} + onClick={openSource} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); - setRelatedOpen(true); + openSource(); } }} > @@ -136,11 +159,16 @@ export function SourceCard({

)} - {source.url && ( + {(source.url || isWikiSource) && ( previewTarget ? ( - - - 查看原文 + + {isWikiSource ? : } + {isWikiSource ? "Wiki 文档" : "查看原文"} ) : ( @@ -148,19 +176,9 @@ export function SourceCard({ )} {previewTarget && ( -
- +
+ {isWikiSource ? "点击卡片查看完整 Wiki" : "点击卡片查看详情"} +
)}
@@ -173,7 +191,7 @@ export function SourceCard({ onOpenChange={setDetailOpen} /> )} - + {!previewTarget &&
@@ -210,13 +228,23 @@ export function SourceCard({ )}
- {paragraphs.length > 0 ? ( -
- {paragraphs.map((paragraph, index) => ( -

- {paragraph} -

- ))} + {content ? ( +
+ {isWikiSource && ( +
+ + 本次检索命中的 Wiki 文档内容 +
+ )} +
+ +
) : null} {source.url && !previewTarget && ( @@ -225,7 +253,7 @@ export function SourceCard({
)}
-
+
} ); } @@ -254,44 +282,18 @@ function compactReferenceText(text: string): string { return text.replace(/\s+/g, " ").trim(); } -function segmentReferenceText(text: string): string[] { - const explicit = text - .split(/\n{2,}/) - .map((item) => item.trim()) - .filter(Boolean); - const base = explicit.length > 1 ? explicit : splitLongParagraph(text); - return base.slice(0, 80); -} - -function splitLongParagraph(text: string): string[] { - const normalized = text - .split(/\n+/) - .map((item) => item.trim()) - .filter(Boolean) - .join("\n"); - if (normalized.length <= 360) { - return normalized ? [normalized] : []; - } - - const sentences = normalized - .split(/(?<=[。!?!?;;])\s*/) - .map((item) => item.trim()) - .filter(Boolean); - if (sentences.length <= 1) { - return normalized.match(/[\s\S]{1,360}/g) ?? []; - } - - const paragraphs: string[] = []; - let current = ""; - for (const sentence of sentences) { - if ((current + sentence).length > 360 && current) { - paragraphs.push(current.trim()); - current = ""; - } - current += sentence; - } - if (current.trim()) { - paragraphs.push(current.trim()); - } - return paragraphs; +function plainTextReferencePreview(text: string): string { + return compactReferenceText( + text + .replace(/^---[\s\S]*?---\s*/m, "") + .replace(/```[\s\S]*?```/g, " ") + .replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1") + .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") + .replace(/\[\[[^\]\s|]+[|\s]+([^\]]+)\]\]/g, "$1") + .replace(/\[\[([^\]]+)\]\]/g, "$1") + .replace(/^#{1,6}\s+/gm, "") + .replace(/^\s*[-*+]\s+/gm, "") + .replace(/^\s*\d+[.)]\s+/gm, "") + .replace(/[>*_`~|]/g, " "), + ); } diff --git a/frontend-web/src/components/workspace/workspace-nav-menu.tsx b/frontend-web/src/components/workspace/workspace-nav-menu.tsx index 8668c4c8..05e15990 100644 --- a/frontend-web/src/components/workspace/workspace-nav-menu.tsx +++ b/frontend-web/src/components/workspace/workspace-nav-menu.tsx @@ -13,6 +13,7 @@ import { HistoryIcon, ListTreeIcon, MessageCircleQuestionIcon, + MessageSquareTextIcon, ScrollTextIcon, Settings2Icon, SettingsIcon, @@ -288,6 +289,16 @@ export function WorkspaceNavMenu() { 推荐问题配置
)} + {isAdmin && ( + + navigate("/page/strategy/admin/fixed-questions") + } + > + + 问题配置 + + )} {isAdmin && logSystem?.enabled && logSystem.url && ( diff --git a/frontend-web/src/core/artifacts/listing.ts b/frontend-web/src/core/artifacts/listing.ts new file mode 100644 index 00000000..978d0a5f --- /dev/null +++ b/frontend-web/src/core/artifacts/listing.ts @@ -0,0 +1,113 @@ +import { apiFetch } from "@/core/api"; +import { getBackendBaseURL } from "@/core/config"; + +export interface ArtifactListingFile { + path: string; + name: string; + sizeBytes: number; + mimeType: string | null; + modifiedAt: string; +} + +export interface ArtifactListing { + threadId: string; + files: ArtifactListingFile[]; +} + +/** + * Scan the thread's outputs directory instead of relying only on artifacts + * explicitly surfaced by `present_files`. This is used by file mode so files + * written by any tool are still discoverable from the right-hand directory. + */ +export async function listThreadArtifacts( + threadId: string, + signal?: AbortSignal, +): Promise { + const response = await apiFetch( + `${getBackendBaseURL()}/api/threads/${encodeURIComponent(threadId)}/artifacts`, + { method: "GET", signal }, + ); + if (!response.ok) { + const detail = await response.text().catch(() => ""); + throw new Error( + `Failed to list artifacts (${response.status})${detail ? `: ${detail}` : ""}`, + ); + } + + const data = (await response.json()) as { + thread_id?: string; + files?: Array<{ + path: string; + name: string; + size_bytes: number; + mime_type: string | null; + modified_at: string; + }>; + }; + + return { + threadId: data.thread_id ?? threadId, + files: (data.files ?? []).map((file) => ({ + path: file.path, + name: file.name, + sizeBytes: file.size_bytes, + mimeType: file.mime_type, + modifiedAt: file.modified_at, + })), + }; +} + +/** Return the generated file path represented by a real or streaming artifact. */ +export function artifactPathname(artifact: string): string { + const trimmed = artifact.trim(); + if (!trimmed.startsWith("write-file:")) { + return trimmed.replace(/^\/+/, ""); + } + try { + return decodeURIComponent(new URL(trimmed).pathname).replace(/^\/+/, ""); + } catch { + return trimmed; + } +} + +export function artifactRelativePath(artifact: string): string { + const pathname = artifactPathname(artifact); + return pathname.replace(/^mnt\/user-data\/outputs\/?/i, "") || pathname; +} + +/** + * Keep the checkpoint order, append files found only on disk from old to new, + * and replace a completed streaming URL with its durable path. + */ +export function mergeArtifactPaths( + checkpointArtifacts: string[], + discoveredArtifacts: string[], +): string[] { + const merged: string[] = []; + const indexByPath = new Map(); + + for (const artifact of checkpointArtifacts) { + const key = artifactPathname(artifact); + if (!key || indexByPath.has(key)) continue; + indexByPath.set(key, merged.length); + merged.push(artifact); + } + + // The listing endpoint returns newest first. Append in chronological order + // so the last item remains the newest artifact for the existing auto-open. + for (const artifact of [...discoveredArtifacts].reverse()) { + const key = artifactPathname(artifact); + if (!key) continue; + const existingIndex = indexByPath.get(key); + if (existingIndex !== undefined) { + if (merged[existingIndex]?.startsWith("write-file:")) { + merged[existingIndex] = artifact; + } + continue; + } + indexByPath.set(key, merged.length); + merged.push(artifact); + } + + return merged; +} diff --git a/frontend-web/src/core/auth/index.ts b/frontend-web/src/core/auth/index.ts index 36eb5542..64ad9bad 100644 --- a/frontend-web/src/core/auth/index.ts +++ b/frontend-web/src/core/auth/index.ts @@ -49,6 +49,17 @@ function normalizeIdentityPart(value: unknown): string { return typeof value === "string" ? value.trim().toLowerCase() : ""; } +function identityFromLoginName(value: unknown): string | null { + const normalized = normalizeIdentityPart(value); + if (!normalized) return null; + // Explicit email login is domain-specific. Bare username login is the + // stable business principal even if the backend's synthetic email domain or + // internal UUID changes. + return normalized.includes("@") + ? `email:${normalized}` + : `account:${normalized}`; +} + function readUserInfoIdentity(): string | null { let info: Record | null = null; try { @@ -81,20 +92,24 @@ function readUserInfoIdentity(): string | null { /** * A stable cross-tab identity key for "did the account really change?" checks. * - * Some external login paths can re-issue a backend ``user_id`` while resolving - * to the same business account. Prefer business-facing identifiers - * (email/username/userInfo) and keep ``user_id`` only as the final fallback. + * Some external login paths can re-issue a backend ``user_id`` or use a + * different synthetic email domain while resolving to the same business + * account. Prefer the raw login username returned by the backend, then the + * email local-part, and keep ``user_id`` only as the final fallback. */ export function getAccountIdentityKey( auth: LoginByUsernameResponse | null = getStoredAuth(), ): string | null { if (!auth) return null; - const email = normalizeIdentityPart(auth.email); - if (email) return `email:${email}`; + const loginIdentity = identityFromLoginName(auth.username); + if (loginIdentity) return loginIdentity; - const username = normalizeIdentityPart(auth.username); - if (username) return `username:${username}`; + const email = normalizeIdentityPart(auth.email); + if (email) { + const localPart = email.split("@", 1)[0]; + return localPart ? `account:${localPart}` : `email:${email}`; + } const userInfoIdentity = readUserInfoIdentity(); if (userInfoIdentity) return `userInfo:${userInfoIdentity}`; diff --git a/frontend-web/src/core/auth/sync.tsx b/frontend-web/src/core/auth/sync.tsx index 65321bd3..b4fc5c95 100644 --- a/frontend-web/src/core/auth/sync.tsx +++ b/frontend-web/src/core/auth/sync.tsx @@ -1,9 +1,15 @@ "use client"; -import { useEffect, useRef } from "react"; +import { Fragment, useEffect, useRef, useState, type ReactNode } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; +import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; -import { getAccountIdentityKey } from "@/core/auth"; +import { + AUTH_STATE_CHANGED_EVENT, + getAccountDisplayName, + getAccountIdentityKey, +} from "@/core/auth"; import { isEmbedActive, isIsolatedSession } from "@/core/embed/embed-session"; const AUTH_STORAGE_KEY = "deerflow.auth"; @@ -14,14 +20,37 @@ function readIdentityKey(): string | null { return getAccountIdentityKey(); } +function freshRouteForAccount(pathname: string): string { + // A thread URL belongs to the old account. Keep its workspace/agent context + // but move to a valid new conversation before the user can submit into the + // stale thread. Other pages can stay in place and are remounted below. + const chatMatch = pathname.match(/^(.*\/chats)\/[^/]+\/?$/); + return chatMatch ? `${chatMatch[1]}/new` : pathname; +} + /** - * Mount once at the app root. Watches localStorage auth changes from other - * tabs and lets the user decide when to refresh on account switches. + * Cross-tab auth boundary for normal pages. + * + * Same-account token re-issuance is ignored. A genuine account change clears + * account-scoped query state, remounts the page, and moves an open chat to its + * fresh-chat route without a full browser reload. Embedded/isolated tabs keep + * their sessionStorage identity and never participate in this synchronization. */ -export function AuthSyncWatcher() { +export function AuthSyncWatcher({ + children, + disabled = false, +}: { + children?: ReactNode; + disabled?: boolean; +}) { const lastIdentityRef = useRef(readIdentityKey()); + const [revision, setRevision] = useState(0); + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const location = useLocation(); useEffect(() => { + if (disabled) return; // 嵌入(iframe)态 / 独立会话态(?isolate=1):鉴权走按标签页的 sessionStorage,本就不与其它标签页 // 共享,且不应被其它标签页的 localStorage 账号变更打扰 —— 直接不挂跨标签监听。 if (isEmbedActive() || isIsolatedSession()) return; @@ -36,18 +65,27 @@ export function AuthSyncWatcher() { lastIdentityRef.current = nextIdentity; + void queryClient.cancelQueries(); + queryClient.clear(); + window.dispatchEvent(new Event(AUTH_STATE_CHANGED_EVENT)); + setRevision((value) => value + 1); + if (nextIdentity === null) { + toast.info("账号已在其它页面退出", { + id: IDENTITY_TOAST_ID, + description: "当前页面已同步退出状态。", + }); + navigate("/login", { replace: true }); return; } - toast.warning("检测到账号在其它标签页发生变化", { + const freshRoute = freshRouteForAccount(location.pathname); + if (freshRoute !== location.pathname) { + navigate(freshRoute, { replace: true }); + } + toast.info("账号已在页面间自动同步", { id: IDENTITY_TOAST_ID, - description: "当前标签页仍是原会话。如需与新账号同步,请刷新页面。", - duration: Infinity, - action: { - label: "刷新同步", - onClick: () => window.location.reload(), - }, + description: `当前账号:${getAccountDisplayName()}。无需刷新页面。`, }); } @@ -68,14 +106,24 @@ export function AuthSyncWatcher() { settleTimer = setTimeout(evaluateSettledIdentity, SETTLE_DELAY_MS); } + function handleSameTabAuthChange() { + // `storage` never fires in the tab that called setStoredAuth/clearStoredAuth. + // Keep the comparison baseline current so a later same-account token + // renewal from another normal page is not compared with the pre-login + // `null` identity and falsely classified as an account switch. + lastIdentityRef.current = readIdentityKey(); + } + window.addEventListener("storage", handleStorage); + window.addEventListener(AUTH_STATE_CHANGED_EVENT, handleSameTabAuthChange); return () => { window.removeEventListener("storage", handleStorage); + window.removeEventListener(AUTH_STATE_CHANGED_EVENT, handleSameTabAuthChange); if (settleTimer) { clearTimeout(settleTimer); } }; - }, []); + }, [disabled, location.pathname, navigate, queryClient]); - return null; + return children ? {children} : null; } diff --git a/frontend-web/src/core/auth/use-live-account.ts b/frontend-web/src/core/auth/use-live-account.ts index 8cfedd8c..b855973f 100644 --- a/frontend-web/src/core/auth/use-live-account.ts +++ b/frontend-web/src/core/auth/use-live-account.ts @@ -8,7 +8,7 @@ import { getAccountIdentityKey, getStoredAuth, } from "@/core/auth"; -import { isEmbedActive } from "@/core/embed/embed-session"; +import { isEmbedActive, isIsolatedSession } from "@/core/embed/embed-session"; const AUTH_STORAGE_KEY = "deerflow.auth"; @@ -65,7 +65,9 @@ export function useLiveAccount(): LiveAccount { refresh(); } } - if (!isEmbedActive()) { + // Normal pages share localStorage identity across tabs. Embedded and + // isolated pages deliberately keep their per-tab sessionStorage identity. + if (!isEmbedActive() && !isIsolatedSession()) { window.addEventListener("storage", onStorage); } window.addEventListener(AUTH_STATE_CHANGED_EVENT, refresh); diff --git a/frontend-web/src/core/i18n/locales/en-US.ts b/frontend-web/src/core/i18n/locales/en-US.ts index de6a7d60..e8a7ad64 100644 --- a/frontend-web/src/core/i18n/locales/en-US.ts +++ b/frontend-web/src/core/i18n/locales/en-US.ts @@ -456,7 +456,7 @@ export const enUS: Translations = { lightDescription: "Bright palette with higher contrast for daytime.", darkDescription: "Dim palette that reduces glare for focus.", lightblueDescription: "A crisp light palette built around blue.", - lightgreenDescription: "A crisp light palette built around WeKnora green.", + lightgreenDescription: "A crisp light palette built around fresh green tones.", iceblueDescription: "An airy palette built around #9BCEF1 with accessible blue actions.", lightyellowDescription: "A soft palette built around #F3FBD9 with readable olive actions.", languageTitle: "Language", diff --git a/frontend-web/src/core/i18n/locales/zh-CN.ts b/frontend-web/src/core/i18n/locales/zh-CN.ts index ab06ad4a..8610ac88 100644 --- a/frontend-web/src/core/i18n/locales/zh-CN.ts +++ b/frontend-web/src/core/i18n/locales/zh-CN.ts @@ -437,7 +437,7 @@ export const zhCN: Translations = { lightDescription: "更明亮的配色,适合日间使用。", darkDescription: "更暗的配色,减少眩光方便专注。", lightblueDescription: "以蓝色为主色的清爽浅色配色。", - lightgreenDescription: "采用 WeKnora 绿色的清爽浅色配色。", + lightgreenDescription: "采用清新绿色的浅色配色。", iceblueDescription: "以 #9BCEF1 为主色,搭配冰蓝留白与沉稳交互色。", lightyellowDescription: "以 #F3FBD9 为主色,搭配柔和浅黄留白与深橄榄交互色。", languageTitle: "语言", diff --git a/frontend-web/src/core/llmwiki/source-links.ts b/frontend-web/src/core/llmwiki/source-links.ts index d91c0845..60f7f677 100644 --- a/frontend-web/src/core/llmwiki/source-links.ts +++ b/frontend-web/src/core/llmwiki/source-links.ts @@ -44,7 +44,7 @@ export function parseWeKnoraPreviewUrl(url?: string | null): WeKnoraSourceTarget params.get("wikiSlug")?.trim() || params.get("slug")?.trim() || ""; - return knowledgeBaseId && chunkId + return knowledgeBaseId && (wikiSlug || chunkId || documentId) ? { knowledgeBaseId, chunkId, @@ -75,6 +75,7 @@ export function resolveWeKnoraSourceTarget(source: KnowledgeSourceLike): WeKnora fromPreview?.documentId || ""; const chunkId = source.chunk_id || source.chunkId || fromPreview?.chunkId || ""; + if (!wikiSlug && !chunkId && !documentId) return null; return { knowledgeBaseId, chunkId: chunkId || undefined, @@ -94,6 +95,43 @@ export function buildWeKnoraSourceIframeUrl(target: WeKnoraSourceTarget): string return `/#/page/workspace/knowledge/${encodeURIComponent(target.knowledgeBaseId)}${query ? `?${query}` : ""}`; } +/** + * Lightweight native reader for one Wiki page. Unlike the knowledge-base + * detail route this does not create a WeKnora iframe session. + */ +export function buildWeKnoraWikiPageUrl( + knowledgeBaseId: string, + wikiSlug: string, +): string { + return `/#${buildWeKnoraWikiPagePath(knowledgeBaseId, wikiSlug)}`; +} + +export function buildWeKnoraWikiPagePath( + knowledgeBaseId: string, + wikiSlug: string, +): string { + const params = new URLSearchParams({ wikiSlug }); + return `/page/workspace/knowledge/${encodeURIComponent(knowledgeBaseId)}/wiki/view?${params.toString()}`; +} + +/** Public, chrome-free Wiki reader intended for third-party iframe embedding. */ +export function buildPublicKnowledgeWikiPath( + knowledgeBaseId: string, + wikiSlug?: string, +): string { + const params = new URLSearchParams(); + if (wikiSlug) params.set("wikiSlug", wikiSlug); + const query = params.toString(); + return `/embed/knowledge/${encodeURIComponent(knowledgeBaseId)}${query ? `?${query}` : ""}`; +} + +export function buildPublicKnowledgeWikiUrl( + knowledgeBaseId: string, + wikiSlug?: string, +): string { + return `/#${buildPublicKnowledgeWikiPath(knowledgeBaseId, wikiSlug)}`; +} + export function buildLegacyKnowledgeSourceUrl(source: KnowledgeSourceLike): string { const recUuid = source.recUuid || source.rec_uuid || source.url || ""; if (!recUuid) return ""; @@ -108,7 +146,9 @@ export function buildLegacyKnowledgeSourceUrl(source: KnowledgeSourceLike): stri export function openKnowledgeSource(source: KnowledgeSourceLike): boolean { const weknoraTarget = resolveWeKnoraSourceTarget(source); const href = weknoraTarget - ? buildWeKnoraSourceIframeUrl(weknoraTarget) + ? weknoraTarget.wikiSlug + ? buildWeKnoraWikiPageUrl(weknoraTarget.knowledgeBaseId, weknoraTarget.wikiSlug) + : buildWeKnoraSourceIframeUrl(weknoraTarget) : buildLegacyKnowledgeSourceUrl(source); if (!href) return false; window.open(href, "_blank", "noopener,noreferrer"); diff --git a/frontend-web/src/core/llmwiki/wiki-page-markdown.ts b/frontend-web/src/core/llmwiki/wiki-page-markdown.ts new file mode 100644 index 00000000..d4a24078 --- /dev/null +++ b/frontend-web/src/core/llmwiki/wiki-page-markdown.ts @@ -0,0 +1,56 @@ +import { buildWeKnoraWikiPageUrl } from "@/core/llmwiki/source-links"; + +export type WikiPageUrlBuilder = ( + knowledgeBaseId: string, + wikiSlug: string, +) => string; + +export function stripWikiFrontmatter(value: string) { + return value.replace(/^\s*---\s*\n[\s\S]*?\n---\s*(?:\n|$)/, "").trim(); +} + +export function safeWikiFileName(value: string) { + return value.replace(/[\\/:*?"<>|\r\n]+/g, "_").replace(/\s+/g, " ").trim() || "wiki-page"; +} + +export function exportableWikiMarkdown(title: string, summary: string, content: string) { + const body = content.trim(); + const hasTitle = /^#\s+/m.test(body); + return [hasTitle ? "" : `# ${title}`, summary && !body.includes(summary) ? `> ${summary}` : "", body] + .filter(Boolean) + .join("\n\n"); +} + +export function wikiLinkMarkdown( + markdown: string, + knowledgeBaseId: string, + buildPageUrl: WikiPageUrlBuilder = buildWeKnoraWikiPageUrl, +) { + const link = (slugValue: string, labelValue?: string) => { + const slug = slugValue.trim(); + const fallbackLabel = slug.split("/").filter(Boolean).at(-1) || slug; + const label = (labelValue || fallbackLabel).trim().replace(/\]/g, "\\]"); + return `[${label}](${buildPageUrl(knowledgeBaseId, slug)})`; + }; + return markdown + .replace(/\[\[([^\]|]+)\|([^\]]+)\]\]/g, (_match, slug, label) => link(slug, label)) + .replace(/\[\[([^\]\s]+)\s+([^\]]+)\]\]/g, (_match, slug, label) => link(slug, label)) + .replace(/\[\[([^\]]+)\]\]/g, (_match, slug) => link(slug)); +} + +export function renderableWikiMarkdown( + title: string, + content: string, + knowledgeBaseId: string, + buildPageUrl?: WikiPageUrlBuilder, +) { + const lines = content.trim().split("\n"); + if (lines[0]?.replace(/^#+\s*/, "").trim() === title.trim()) { + lines[0] = `# ${title}`; + } + return wikiLinkMarkdown( + exportableWikiMarkdown(title, "", lines.join("\n")), + knowledgeBaseId, + buildPageUrl, + ); +} diff --git a/frontend-web/src/core/llmwiki/wiki-tree.ts b/frontend-web/src/core/llmwiki/wiki-tree.ts new file mode 100644 index 00000000..1db7ebc6 --- /dev/null +++ b/frontend-web/src/core/llmwiki/wiki-tree.ts @@ -0,0 +1,146 @@ +import type { LlmWikiWikiPage } from "@/strategy-components/api/llmwiki"; + +export type WikiTreeNode = { + key: string; + label: string; + page: LlmWikiWikiPage | null; + children: WikiTreeNode[]; + count: number; +}; + +export type WikiTree = { + roots: WikiTreeNode[]; + parentKeysBySlug: Map; +}; + +function pageDirectoryParts(page: LlmWikiWikiPage): string[] { + const wikiPath = (page.wiki_path || "").split("/").map((item) => item.trim()).filter(Boolean); + if (wikiPath.length > 2) return wikiPath.slice(1, -1); + const category = page.category_path?.filter(Boolean) || []; + if (category.length > 0) return category; + const parts = (page.slug || "").split("/").filter(Boolean); + return parts.length > 1 ? parts.slice(0, -1) : ["未分类"]; +} + +export function filterWikiPagesWithAncestors( + pages: LlmWikiWikiPage[], + query: string, +): LlmWikiWikiPage[] { + const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean); + if (words.length === 0) return pages; + const matched = pages.filter((page) => { + const haystack = [ + page.title, + page.slug, + page.summary, + page.page_type, + page.parent_slug, + ...(page.aliases || []), + ...(page.category_path || []), + ] + .filter(Boolean) + .join(" ") + .toLowerCase(); + return words.every((word) => haystack.includes(word)); + }); + const bySlug = new Map(pages.map((page) => [page.slug, page])); + const result = new Map(); + for (const page of matched) { + let current: LlmWikiWikiPage | undefined = page; + const visited = new Set(); + while (current?.slug && !visited.has(current.slug)) { + visited.add(current.slug); + result.set(current.slug, current); + current = current.parent_slug ? bySlug.get(current.parent_slug) : undefined; + } + } + return Array.from(result.values()); +} + +function attachNode(parent: WikiTreeNode | null, roots: WikiTreeNode[], node: WikiTreeNode) { + const siblings = parent ? parent.children : roots; + if (!siblings.some((item) => item.key === node.key)) siblings.push(node); +} + +function sortWikiNodes(nodes: WikiTreeNode[]) { + nodes.sort((left, right) => { + if (!!left.children.length !== !!right.children.length) return left.children.length ? -1 : 1; + return left.label.localeCompare(right.label, "zh-Hans-CN"); + }); + nodes.forEach((node) => sortWikiNodes(node.children)); +} + +function countPages(node: WikiTreeNode): number { + node.count = (node.page ? 1 : 0) + node.children.reduce((sum, child) => sum + countPages(child), 0); + return node.count; +} + +export function buildWikiTree(pages: LlmWikiWikiPage[]): WikiTree { + const roots: WikiTreeNode[] = []; + const pageNodes = new Map(); + const directoryNodes = new Map(); + const parentKeysBySlug = new Map(); + + for (const page of pages) { + if (!page.slug) continue; + pageNodes.set(page.slug, { + key: `page:${page.slug}`, + label: page.title || page.slug, + page, + children: [], + count: 1, + }); + } + + const directoryNode = (path: string[]) => { + const key = `dir:${path.join("/")}`; + const existing = directoryNodes.get(key); + if (existing) return existing; + const node: WikiTreeNode = { + key, + label: path[path.length - 1] || "未分类", + page: null, + children: [], + count: 0, + }; + directoryNodes.set(key, node); + return node; + }; + + for (const page of pages) { + const node = pageNodes.get(page.slug); + if (!node) continue; + const ancestorKeys: string[] = []; + const parentSlug = page.parent_slug?.trim(); + if (parentSlug && pageNodes.has(parentSlug)) { + const parent = pageNodes.get(parentSlug)!; + attachNode(parent, roots, node); + ancestorKeys.push(parent.key); + let current = parent.page?.parent_slug ? pageNodes.get(parent.page.parent_slug) : undefined; + const seen = new Set([parent.key]); + while (current && !seen.has(current.key)) { + seen.add(current.key); + ancestorKeys.unshift(current.key); + current = current.page?.parent_slug ? pageNodes.get(current.page.parent_slug) : undefined; + } + parentKeysBySlug.set(page.slug, ancestorKeys); + continue; + } + + let parent: WikiTreeNode | null = null; + const path: string[] = []; + for (const part of pageDirectoryParts(page)) { + path.push(part); + const directory = directoryNode(path); + attachNode(parent, roots, directory); + parent = directory; + ancestorKeys.push(directory.key); + } + attachNode(parent, roots, node); + parentKeysBySlug.set(page.slug, ancestorKeys); + } + + roots.forEach(countPages); + sortWikiNodes(roots); + return { roots, parentKeysBySlug }; +} diff --git a/frontend-web/src/core/settings/local.ts b/frontend-web/src/core/settings/local.ts index b9c86665..c79ec805 100644 --- a/frontend-web/src/core/settings/local.ts +++ b/frontend-web/src/core/settings/local.ts @@ -4,10 +4,11 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = { notification: { enabled: true, }, - display: { - streamSpeedMeterEnabled: true, - ragModeEnabled: false, - contextCompressionEnabled: false, + display: { + streamSpeedMeterEnabled: true, + ragModeEnabled: false, + artifactDirectoryModeEnabled: false, + contextCompressionEnabled: false, citationCleanupWords: [], fontScale: 1, }, @@ -43,7 +44,12 @@ export interface LocalSettings { * Global switch for skill result display adapters. When disabled, chat * messages render tool results with the legacy generic search UI only. */ - ragModeEnabled: boolean; + ragModeEnabled: boolean; + /** + * When enabled, the artifact panel scans every generated output and keeps + * a directory tree visible beside the selected file preview. + */ + artifactDirectoryModeEnabled: boolean; /** * Per-user switch for context compression (上下文压缩). When enabled, long * conversations are summarized transiently before the model call to save diff --git a/frontend-web/src/core/threads/hooks.ts b/frontend-web/src/core/threads/hooks.ts index a46229e0..e5cc5bce 100644 --- a/frontend-web/src/core/threads/hooks.ts +++ b/frontend-web/src/core/threads/hooks.ts @@ -761,17 +761,15 @@ export function useThreadStream({ } return; } - // A "thread not found" 404 almost always means the URL points at a - // thread from a previous login session — either this tab outlived a - // logout/re-login or the thread genuinely belongs to a different user. - // Re-frame the error around the *current* logged-in identity and bounce - // the user to a fresh chat instead of leaving them stuck on the stale - // route looking at a raw HTTP 404. - if (isThreadNotAccessibleError(message)) { - const userLabel = buildCurrentUserLabel(); - toast.info( - `当前登录用户为 ${userLabel},原对话不存在或无权访问,正在为你刷新页面…`, - ); + // A "thread not found" 404 means this route cannot accept another turn. + // Move to a fresh chat without a full browser reload; the old reload path + // made every click look like a crash and could loop indefinitely when a + // different tab's cookie kept overriding this tab's Bearer token. + if (isThreadNotAccessibleError(message)) { + const userLabel = buildCurrentUserLabel(); + toast.info( + `当前登录用户为 ${userLabel},原对话不存在或无权访问,已为你打开新对话。`, + ); // Prefer staying inside an agent chat context when the user is on an // agent thread URL — otherwise fall back to the global new chat. const hash = typeof window !== "undefined" ? window.location.hash : ""; @@ -782,18 +780,7 @@ export function useThreadStream({ : iframeMatch ? "/page/workspace/chats_iframe/new" : "/page/workspace/chats/new"; - // First navigate to a safe URL so the post-reload page lands on a - // valid route. Then full-reload so TanStack Query cache, context - // providers and any other state captured under the previous user's - // identity get rebuilt against the current login. - if (typeof window !== "undefined") { - window.location.hash = target; - window.setTimeout(() => { - window.location.reload(); - }, 900); - } else { - navigate(target, { replace: true }); - } + navigate(target, { replace: true }); return; } // 上一轮还没结束又发了新问题(409)或并发达到上限(429): diff --git a/frontend-web/src/core/threads/thread-access-error.ts b/frontend-web/src/core/threads/thread-access-error.ts index 4b01f74f..266b0f2a 100644 --- a/frontend-web/src/core/threads/thread-access-error.ts +++ b/frontend-web/src/core/threads/thread-access-error.ts @@ -13,8 +13,9 @@ import { getStoredAuth } from "@/core/auth"; * 404 just leaves the user staring at a scary error. Both the chat stream * (``core/threads/hooks.ts``) and the AI-writing stream * (``open-canvas/contexts/AIWritingContext.tsx``) funnel their stream errors - * through here so the UX is identical: detect the login-change 404 and prompt - * the user to refresh against the current identity instead of erroring out. + * through here so the UX is identical: detect the login-change 404 and reset + * the stale in-memory session against the current identity instead of showing + * a raw error or reloading the browser page. */ /** Normalize an unknown stream error into a human-readable message string. */ @@ -108,22 +109,15 @@ export function buildCurrentUserLabel(): string { } /** - * Re-frame a login-change 404 around the current identity and reload the page. + * Re-frame a login-change 404 around the current identity and reset its view. * - * Shows a friendly toast naming the current login, then full-reloads after a - * short delay so the toast is visible and so TanStack Query cache / context - * providers / any state captured under the previous user get rebuilt against - * the current login. Used by streams whose session id lives in component state - * (not the URL) — a plain reload lands them back on a fresh, valid page. + * Streams whose session id lives in component state pass a local reset callback + * so they can discard the inaccessible session without a browser reload. */ -export function notifyThreadAccessChangedAndReload(): void { +export function notifyThreadAccessChanged(onReset: () => void): void { const userLabel = buildCurrentUserLabel(); toast.info( - `当前登录用户为 ${userLabel},原会话不存在或无权访问,正在为你刷新页面…`, + `当前登录用户为 ${userLabel},原会话不存在或无权访问,已为你重置为新会话。`, ); - if (typeof window !== "undefined") { - window.setTimeout(() => { - window.location.reload(); - }, 900); - } + onReset(); } diff --git a/frontend-web/src/open-canvas/contexts/AIWritingContext.tsx b/frontend-web/src/open-canvas/contexts/AIWritingContext.tsx index 2b0b9f0a..de4e300f 100644 --- a/frontend-web/src/open-canvas/contexts/AIWritingContext.tsx +++ b/frontend-web/src/open-canvas/contexts/AIWritingContext.tsx @@ -18,7 +18,7 @@ import { getAPIClient } from '@/core/api'; import { getStreamErrorMessage, isThreadNotAccessibleError, - notifyThreadAccessChangedAndReload, + notifyThreadAccessChanged, } from '@/core/threads/thread-access-error'; import { useCanvasGraphContext } from '../contexts/CanvasGraphContext'; @@ -299,10 +299,13 @@ const AIWritingStreamHost = memo(function AIWritingStreamHost({ // thread belongs to a previous login session — the user switched accounts // (or this tab outlived a re-login), so the current account legitimately // can't access the old thread. Instead of dumping the raw HTTP 404 into - // the panel, re-frame it around the current identity and reload — same UX - // the chat stream gives (see core/threads/hooks.ts). + // the panel, re-frame it around the current identity and discard the + // stale component-state session without reloading the browser page. if (isThreadNotAccessibleError(message)) { - notifyThreadAccessChangedAndReload(); + notifyThreadAccessChanged(() => { + continueTargetRef.current = null; + dispatchRef.current?.({ type: 'reset' }); + }); return; } dispatchRef.current?.({ type: 'set_error', message }); @@ -410,6 +413,13 @@ export function AIWritingProvider({ children }: { children: ReactNode }) { // (刷新会断开 SSE,后端默认 on_disconnect=cancel 取消该 run,不留孤儿)。 const threadId = state.sessionId ?? undefined; + const resetAfterThreadAccessError = useCallback(() => { + setArtifact(null); + seenInterruptRef.current = null; + continueTargetRef.current = null; + dispatch({ type: 'reset' }); + }, [setArtifact]); + useEffect(() => { const s = state.status; const savable = s === 'done' || s === 'error' || s.startsWith('awaiting_'); @@ -473,12 +483,12 @@ export function AIWritingProvider({ children }: { children: ReactNode }) { } catch (err) { const message = getStreamErrorMessage(err); if (isThreadNotAccessibleError(message)) { - notifyThreadAccessChangedAndReload(); + notifyThreadAccessChanged(resetAfterThreadAccessError); return; } dispatch({ type: 'set_error', message }); } - }, [state.status]); + }, [state.status, resetAfterThreadAccessError]); const submitIntervention = useCallback(async (payload: UserInterventionPayload) => { if (!state.sessionId || !state.currentPause) return; @@ -585,14 +595,14 @@ export function AIWritingProvider({ children }: { children: ReactNode }) { } catch (err) { const message = getStreamErrorMessage(err); if (isThreadNotAccessibleError(message)) { - notifyThreadAccessChangedAndReload(); + notifyThreadAccessChanged(resetAfterThreadAccessError); return; } dispatch({ type: 'set_error', message }); } }, [state.sessionId, state.currentPause, state.materials, state.outline, state.currentDraftTitle, state.currentDraftMarkdown, state.reviewResult, - state.status]); + state.status, resetAfterThreadAccessError]); const pauseWriting = useCallback(() => { // 真停:取消当前 run(graph 已完成的阶段已落 checkpoint)。继续时从该 checkpoint 续跑。 @@ -626,13 +636,13 @@ export function AIWritingProvider({ children }: { children: ReactNode }) { } catch (err) { const message = getStreamErrorMessage(err); if (isThreadNotAccessibleError(message)) { - notifyThreadAccessChangedAndReload(); + notifyThreadAccessChanged(resetAfterThreadAccessError); return; } dispatch({ type: 'set_error', message }); } })(); - }, [state.request]); + }, [state.request, resetAfterThreadAccessError]); // Restore an unfinished history session: make it active, load its checkpoint, then let // the host skip completed awaiting_* stages or re-run the current unfinished stage. diff --git a/frontend-web/src/pages/AgentChatPage.tsx b/frontend-web/src/pages/AgentChatPage.tsx index 86c4583d..4487813a 100644 --- a/frontend-web/src/pages/AgentChatPage.tsx +++ b/frontend-web/src/pages/AgentChatPage.tsx @@ -776,6 +776,17 @@ export default function AgentChatPage({ onContextChange={(context) => setSettings("context", context) } + knowledgeSelector={ + + } onFollowupsVisibilityChange={setShowFollowups} onSubmit={handleSubmit} onStop={handleStop} diff --git a/frontend-web/src/pages/ChatPage.tsx b/frontend-web/src/pages/ChatPage.tsx index 5e95df8e..260d62fd 100644 --- a/frontend-web/src/pages/ChatPage.tsx +++ b/frontend-web/src/pages/ChatPage.tsx @@ -838,20 +838,19 @@ export default function ChatPage() { : undefined } knowledgeSelector={ - isLandingState ? ( - - ) : undefined + } onFollowupsVisibilityChange={setShowFollowups} onSubmit={handleSubmit} diff --git a/frontend-web/src/pages/EnterpriseResearchPage.tsx b/frontend-web/src/pages/EnterpriseResearchPage.tsx index 9c2118bb..1f5bdcdb 100644 --- a/frontend-web/src/pages/EnterpriseResearchPage.tsx +++ b/frontend-web/src/pages/EnterpriseResearchPage.tsx @@ -37,11 +37,11 @@ import { cn } from "@/lib/utils"; import { openKnowledgeSource } from "@/core/llmwiki/source-links"; import MarkdownRenderer from "@/strategy-components/MarkdownRenderer"; import { - type LlmWikiSearchResult, useLlmWikiKnowledgeBases, useLlmWikiRuntime, } from "@/strategy-components/api/llmwiki"; import { + type EnterpriseResearchEvidence, type EnterpriseResearchTask, type EnterpriseResearchTaskStatus, type EnterpriseResearchReportEvent, @@ -636,7 +636,7 @@ function EvidencePane({ onStartReport, onCancelReport, onRestoreReport, onSourceSelect, }: { task: EnterpriseResearchTask | null; - source: LlmWikiSearchResult | null; + source: EnterpriseResearchEvidence | null; reportJob: EnterpriseResearchReportJob | null; reportJobs: EnterpriseResearchReportJob[]; reportDraft: string; @@ -665,7 +665,7 @@ function EvidencePane({ const latestRewrite = reportJobs.find((job) => job.action === "rewrite" && job.status === "completed" && job.original_report); const latestCommittedJob = reportJobs.find((job) => job.action !== "followup" && job.status === "completed"); const completedFollowups = reportJobs.filter((job) => job.action === "followup" && job.status === "completed"); - const selectedSources = task.selected_source_ids.map((id) => task.sources.find((item) => item.chunk_id === id)).filter(Boolean) as LlmWikiSearchResult[]; + const selectedSources = task.selected_source_ids.map((id) => task.sources.find((item) => item.chunk_id === id)).filter(Boolean) as EnterpriseResearchEvidence[]; const citationIndex = new Map(selectedSources.map((item, index) => [item.chunk_id, index + 1])); const renderedReport = currentReport.replace(/\[资料:([^\]]+)\]/g, (match, id: string) => { const index = citationIndex.get(id.trim()); diff --git a/frontend-web/src/pages/FixedQuestionsPage.tsx b/frontend-web/src/pages/FixedQuestionsPage.tsx new file mode 100644 index 00000000..98e276da --- /dev/null +++ b/frontend-web/src/pages/FixedQuestionsPage.tsx @@ -0,0 +1,310 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { Navigate } from "react-router-dom"; +import { + Info, + MessageSquareText, + Plus, + Save, + Search, + Trash2, + Zap, +} from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { Textarea } from "@/components/ui/textarea"; +import { getStoredAuth } from "@/core/auth"; +import { + type FixedQuestion, + useFixedQuestionActions, + useFixedQuestions, +} from "@/strategy-components/api/fixed-questions"; + +let sequence = 1; + +function newId(): string { + if (typeof crypto !== "undefined" && "randomUUID" in crypto) { + return crypto.randomUUID(); + } + return `fixed-${Date.now()}-${sequence++}`; +} + +function serialize(rows: FixedQuestion[]): string { + return JSON.stringify( + rows + .map(({ id, question, answer, enabled, tokensPerSecond, sortOrder }) => ({ + id, + question, + answer, + enabled, + tokensPerSecond, + sortOrder, + })) + .sort((a, b) => a.id.localeCompare(b.id)), + ); +} + +export default function FixedQuestionsPage() { + const isAdmin = getStoredAuth()?.system_role === "admin"; + const { data: serverQuestions = [], isLoading } = useFixedQuestions(); + const { save } = useFixedQuestionActions(); + const [rows, setRows] = useState([]); + const [loadedKey, setLoadedKey] = useState(null); + const [query, setQuery] = useState(""); + + const sourceKey = useMemo( + () => serialize(serverQuestions), + [serverQuestions], + ); + useEffect(() => { + if (loadedKey !== sourceKey) { + setRows(serverQuestions); + setLoadedKey(sourceKey); + } + }, [loadedKey, serverQuestions, sourceKey]); + + const dirty = useMemo(() => serialize(rows) !== sourceKey, [rows, sourceKey]); + const search = query.trim().toLowerCase(); + const visibleRows = useMemo( + () => + search + ? rows.filter( + (row) => + row.question.toLowerCase().includes(search) || + row.answer.toLowerCase().includes(search), + ) + : rows, + [rows, search], + ); + const enabledCount = rows.filter( + (row) => row.enabled && row.question.trim() && row.answer.trim(), + ).length; + + if (!isAdmin) { + return ; + } + + const patchRow = (id: string, patch: Partial) => { + setRows((current) => + current.map((row) => (row.id === id ? { ...row, ...patch } : row)), + ); + }; + + const addRow = () => { + setRows((current) => [ + { + id: newId(), + question: "", + answer: "", + enabled: true, + tokensPerSecond: 100, + sortOrder: current.length, + }, + ...current, + ]); + }; + + const removeRow = (id: string) => { + setRows((current) => current.filter((row) => row.id !== id)); + }; + + const handleSave = async () => { + const cleaned = rows.map((row, index) => ({ + ...row, + question: row.question.trim(), + tokensPerSecond: Math.round(row.tokensPerSecond), + sortOrder: index, + })); + for (const row of cleaned) { + if (!row.question) { + toast.error("问题不能为空"); + return; + } + if (!row.answer.trim()) { + toast.error(`问题「${row.question}」的回答不能为空`); + return; + } + if ( + !Number.isFinite(row.tokensPerSecond) || + row.tokensPerSecond < 1 || + row.tokensPerSecond > 1000 + ) { + toast.error(`问题「${row.question}」的流式速度须为 1–1000 token/s`); + return; + } + } + const seen = new Set(); + for (const row of cleaned) { + if (seen.has(row.question)) { + toast.error(`问题「${row.question}」重复,请删除或修改重复项`); + return; + } + seen.add(row.question); + } + try { + await save.mutateAsync(cleaned); + toast.success("问题配置已保存并生效"); + } catch (error) { + toast.error(error instanceof Error ? error.message : "保存失败"); + } + }; + + return ( +
+
+
+
+
+
+ +
+
+
+

问题配置

+ + {rows.length} 条 / {enabledCount} 条启用 + +
+

+ 为完全相同的问题配置固定回答,命中时直接流式返回,不调用大模型。 +

+
+
+
+ + +
+
+
+ + + 匹配区分大小写、标点和空格;停用的配置会保留但不参与匹配。带附件或图片的提问不会使用固定回答。 + +
+
+ +
+
+ + setQuery(event.target.value)} + placeholder="搜索问题或回答…" + className="pl-9" + /> +
+
+ + 启用项保存后立即生效 +
+
+ + {isLoading ? ( +
+ 加载中… +
+ ) : visibleRows.length === 0 ? ( +
+ {rows.length === 0 + ? "暂无问题配置,点击「新增问题」开始配置" + : "没有匹配的问题或回答"} +
+ ) : ( +
+ {visibleRows.map((row, index) => ( +
+
+
+ + {index + 1} + + 固定问答 +
+
+ + + +
+
+
+