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"
+ />
+ ) : (
+
+ )}
+
+ );
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"
- />
- ) : (
-
- )}
-
- )}
+ ) : null}
diff --git a/frontend-web/src/components/workspace/input-box.tsx b/frontend-web/src/components/workspace/input-box.tsx
index 73a18a89..9aa8d780 100644
--- a/frontend-web/src/components/workspace/input-box.tsx
+++ b/frontend-web/src/components/workspace/input-box.tsx
@@ -2,12 +2,13 @@
import type { ChatStatus } from "ai";
import {
- BarChart3Icon,
- BrainCircuitIcon,
- BookMarkedIcon,
- CheckIcon,
- ClipboardCheckIcon,
- FileTextIcon,
+ BarChart3Icon,
+ BrainCircuitIcon,
+ BookMarkedIcon,
+ CheckIcon,
+ ClipboardCheckIcon,
+ FileTextIcon,
+ FolderTreeIcon,
GraduationCapIcon,
LightbulbIcon,
MessageCircleQuestionIcon,
@@ -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}
/>
)}
- }
>
);
}
@@ -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} 条启用
+
+
+
+ 为完全相同的问题配置固定回答,命中时直接流式返回,不调用大模型。
+
+
+
+
+
+
+
+
+
+
+
+ 匹配区分大小写、标点和空格;停用的配置会保留但不参与匹配。带附件或图片的提问不会使用固定回答。
+
+
+
+
+
+
+ {isLoading ? (
+
+ 加载中…
+
+ ) : visibleRows.length === 0 ? (
+
+ {rows.length === 0
+ ? "暂无问题配置,点击「新增问题」开始配置"
+ : "没有匹配的问题或回答"}
+
+ ) : (
+
+ {visibleRows.map((row, index) => (
+
+
+
+
+ {index + 1}
+
+ 固定问答
+
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/frontend-web/src/pages/LlmWikiKnowledgePage.tsx b/frontend-web/src/pages/LlmWikiKnowledgePage.tsx
index a54b097e..2e3d745d 100644
--- a/frontend-web/src/pages/LlmWikiKnowledgePage.tsx
+++ b/frontend-web/src/pages/LlmWikiKnowledgePage.tsx
@@ -1,6 +1,6 @@
import { Loader2, Settings2 } from "lucide-react";
import { useState } from "react";
-import { Navigate } from "react-router-dom";
+import { Navigate, useParams, useSearchParams } from "react-router-dom";
import { Button } from "@/components/ui/button";
import {
@@ -10,6 +10,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { getStoredAuth } from "@/core/auth";
+import { buildWeKnoraWikiPagePath } from "@/core/llmwiki/source-links";
import { useLlmWikiRuntime } from "@/strategy-components/api/llmwiki";
import WeKnoraKnowledgeWorkspace, {
ConnectionSettings,
@@ -66,7 +67,14 @@ export default function LlmWikiKnowledgePage({
}: {
mode?: LlmWikiKnowledgePageMode;
}) {
+ const { noteId = "" } = useParams();
+ const [searchParams] = useSearchParams();
const runtime = useLlmWikiRuntime();
+ const wikiSlug = searchParams.get("wikiSlug")?.trim() || "";
+
+ if (mode === "detail" && noteId && wikiSlug) {
+ return ;
+ }
if (runtime.isLoading) {
return ;
diff --git a/frontend-web/src/pages/StrategyRoutes.tsx b/frontend-web/src/pages/StrategyRoutes.tsx
index f8843bb4..792017cd 100644
--- a/frontend-web/src/pages/StrategyRoutes.tsx
+++ b/frontend-web/src/pages/StrategyRoutes.tsx
@@ -47,6 +47,7 @@ import LightAppListPage from "./LightAppListPage";
import LightAppManagementPage from "./LightAppManagementPage";
import MenuManagementPage from "./MenuManagementPage";
import LoginWhitelistPage from "./LoginWhitelistPage";
+import FixedQuestionsPage from "./FixedQuestionsPage";
import SensitiveWordsPage from "./SensitiveWordsPage";
import SkillAddressesPage from "./SkillAddressesPage";
import TaskButtonsPage from "./TaskButtonsPage";
@@ -454,6 +455,16 @@ export default function StrategyRoutes() {
)
}
/>
+
+ ) : (
+
+ )
+ }
+ />
}
/>
+
+
+
+ }
+ />
structure.content.trim() === content)?.title ?? null;
}, [appliedContent, structuresQuery.data]);
const buttonLabel = selectedWorkflowTitle || "工作流配置";
+ const isSelected = Boolean(selectedWorkflowTitle);
return (
<>