From b89933e60ab5b8f94c364ec8e74f87da8ad282f6 Mon Sep 17 00:00:00 2001 From: Rocky Kwok Date: Mon, 22 Jun 2026 10:55:00 +0800 Subject: [PATCH 1/8] chore: ignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index de6dbc5f..c7d89ebd 100644 --- a/.gitignore +++ b/.gitignore @@ -104,3 +104,6 @@ docs/backups # uv lockfile (not tracked per project preference) offline-backend-20260512/backend/uv.lock + +# misc +.DS_Store \ No newline at end of file From 42ce20b4df7624e640f1c141dde22da96e8a00fb Mon Sep 17 00:00:00 2001 From: xuhd Date: Wed, 17 Jun 2026 15:00:50 +0800 Subject: [PATCH 2/8] =?UTF-8?q?feat(frontend):=20=E6=9D=A5=E6=BA=90?= =?UTF-8?q?=E5=BC=B9=E7=AA=97=E5=8A=A0loading=20+=20=E7=AC=94=E8=AE=B0?= =?UTF-8?q?=E9=A1=B5=E6=9A=97=E8=89=B2=E9=80=82=E9=85=8D=20+=20=E4=B8=BB?= =?UTF-8?q?=E9=A2=98=E9=80=8F=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 来源上传弹窗 A系统基因库 tab 搜索时显示 loading spinner - NetworkActor 组件初始加载时显示 loading spinner(初始状态设为 true) - StudioNotebooksPage 笔记卡片支持暗色主题样式 - StudioNotebookDetailPage / NotebookAIWritingPage 跳转时透传 themekey 参数 Co-Authored-By: Claude Sonnet 4.6 --- .../assistant-ui/document-upload-popup.tsx | 8 +++++- .../components/assistant-ui/network-actor.tsx | 7 ++++- .../src/pages/NotebookAIWritingPage.tsx | 14 ++++++++++ .../src/pages/StudioNotebookDetailPage.tsx | 6 +++-- .../src/pages/StudioNotebooksPage.tsx | 27 ++++++++++++------- 5 files changed, 49 insertions(+), 13 deletions(-) diff --git a/frontend-web/src/components/assistant-ui/document-upload-popup.tsx b/frontend-web/src/components/assistant-ui/document-upload-popup.tsx index 98fcdbe4..a0afd6d5 100644 --- a/frontend-web/src/components/assistant-ui/document-upload-popup.tsx +++ b/frontend-web/src/components/assistant-ui/document-upload-popup.tsx @@ -826,7 +826,13 @@ const DocumentUploadPopupContent: FC<{ )} - {listData.length === 0 && } + {isSearching ? ( +
+ +
+ ) : ( + listData.length === 0 && + )} {searchType === "human" && (
{listData.map((item: listDataItem, index: number) => { diff --git a/frontend-web/src/components/assistant-ui/network-actor.tsx b/frontend-web/src/components/assistant-ui/network-actor.tsx index 9be9cfcd..a48576cb 100644 --- a/frontend-web/src/components/assistant-ui/network-actor.tsx +++ b/frontend-web/src/components/assistant-ui/network-actor.tsx @@ -545,7 +545,12 @@ export function NetworkActor({ inputValue, searchSpaceId, onSuccess }: NetworkAc
)}
-
+ {isSearching ? ( +
+ +
+ ) : null} +
{userList.map((item, index) => (
diff --git a/frontend-web/src/pages/NotebookAIWritingPage.tsx b/frontend-web/src/pages/NotebookAIWritingPage.tsx index 8c818d68..e23e5f24 100644 --- a/frontend-web/src/pages/NotebookAIWritingPage.tsx +++ b/frontend-web/src/pages/NotebookAIWritingPage.tsx @@ -2,6 +2,9 @@ import { useEffect, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import AIWritingPage from '@/open-canvas/pages/AIWritingPage'; import { getNotebookApiBaseURL } from '@/core/config'; +import { useTheme, type ThemeType } from '@/contexts/ThemeContext'; + +const VALID_THEMES: ThemeType[] = ['dark', 'light', 'lightblue']; interface NotebookTokenData { doc_ids: number[]; @@ -13,6 +16,17 @@ interface NotebookTokenData { export default function NotebookAIWritingPage() { const [searchParams] = useSearchParams(); const token = searchParams.get('token'); + const themekey = searchParams.get('themekey'); + const { setTheme } = useTheme(); + + // 跟随调用方传入的 themekey 切换主题;缺省或非法值兜底为 light + useEffect(() => { + const next = themekey && VALID_THEMES.includes(themekey as ThemeType) + ? (themekey as ThemeType) + : 'light'; + setTheme(next); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [themekey]); const [data, setData] = useState(null); const [spaceName, setSpaceName] = useState(null); diff --git a/frontend-web/src/pages/StudioNotebookDetailPage.tsx b/frontend-web/src/pages/StudioNotebookDetailPage.tsx index b0747e85..83d367f4 100644 --- a/frontend-web/src/pages/StudioNotebookDetailPage.tsx +++ b/frontend-web/src/pages/StudioNotebookDetailPage.tsx @@ -27,6 +27,7 @@ import { fetchDocumentByChunk } from "@/core/studio/api"; import type { DocumentWithChunks } from "@/core/studio/api"; import type { SourceItem } from "@/core/studio/types"; import { getStoredAuth } from "@/core/auth"; +import { useTheme } from "@/contexts/ThemeContext"; import { useModels } from "@/core/models/hooks"; import { Select, @@ -218,6 +219,7 @@ export default function StudioNotebookDetailPage({ const params = useParams<{ notebookId: string }>(); const notebookId = notebookIdProp != null ? String(notebookIdProp) : params.notebookId; + const { theme } = useTheme(); // 跳转「我的空间」时带上当前主题 key const { notebooks, isLoading, token: studioToken } = useStudioNotebooks(); // token 用于跳转 callback const { models } = useModels(); const queryClient = useQueryClient(); @@ -508,7 +510,7 @@ export default function StudioNotebookDetailPage({
{/* 右上角跳转按钮 */} -
+
+
-
-
+
+
); @@ -37,11 +41,13 @@ function NotebookCardSkeleton() { function NotebookCard({ nb, + isDark, onRename, onDelete, onOpen, }: { nb: StudioNotebook; + isDark: boolean; onRename: (nb: StudioNotebook) => void; onDelete: (nb: StudioNotebook) => void; /** 传入则点击卡片回调(内嵌打开),不再路由跳转。 */ @@ -61,16 +67,16 @@ function NotebookCard({
- + {nb.name} - {nb.source_count} 个来源 + {nb.source_count} 个来源
); return ( -
+
{onOpen ? ( + ) : ( + + + + {triggerLabel} - )} - + {selectedAgent && ( + { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onAgentChange(null); + } + }} + > + + + )} + + )} 选择 Agent diff --git a/frontend-web/src/pages/IframeChatPage.tsx b/frontend-web/src/pages/IframeChatPage.tsx index 21422426..ea526b4d 100644 --- a/frontend-web/src/pages/IframeChatPage.tsx +++ b/frontend-web/src/pages/IframeChatPage.tsx @@ -5,8 +5,8 @@ import { BrainCircuitIcon, FileTextIcon, GraduationCapIcon, + ShrinkIcon, } from "lucide-react"; -import { useRouter } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { type PromptInputMessage } from "@/components/ai-elements/prompt-input"; @@ -46,6 +46,8 @@ import { type BrainyChatSubmitMessage, parseBrainyChatSubmitMessage, } from "./iframe-chat-submit"; +import { resolveIframeAssistantId } from "./iframe-chat-agent"; +import { buildIframeModeButtons } from "./iframe-chat-mode-buttons"; type BrainyChatStatus = "started" | "finished"; type BrainyAuthTokenMessage = { @@ -93,7 +95,6 @@ function postBrainyAuthToken(accessToken: string) { export default function IframeChatPage() { const routeBase = useRouteBase(); - const router = useRouter(); const { t } = useI18n(); const [hasStartedConversation, setHasStartedConversation] = useState(false); const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } = @@ -152,11 +153,19 @@ export default function IframeChatPage() { }, [selectedAgentId, selectorAgents, selectorLoading]); const { showNotification } = useNotification(); + const activeAssistantId = resolveIframeAssistantId({ + selectedAgentId, + threadMetadataAgentId: null, + }); const [thread, sendMessage, isUploading] = useThreadStream({ threadId: isNewThread ? undefined : threadId, - context: settings.context, + context: { + ...settings.context, + ...(activeAssistantId ? { agent_id: activeAssistantId } : {}), + }, isMock, + assistantId: activeAssistantId, onStart: (createdThreadId) => { setThreadId(createdThreadId); setIsNewThread(false); @@ -193,6 +202,22 @@ export default function IframeChatPage() { }, }); + const threadMetadataAgentId = (() => { + const metadata = (thread as { metadata?: Record | null }) + .metadata; + return typeof metadata?.agent_id === "string" ? metadata.agent_id : null; + })(); + + useEffect(() => { + const resolvedAgentId = resolveIframeAssistantId({ + selectedAgentId, + threadMetadataAgentId, + }); + if (resolvedAgentId && resolvedAgentId !== selectedAgentId) { + setSelectedAgentId(resolvedAgentId); + } + }, [selectedAgentId, threadMetadataAgentId]); + const writingArtifactPath = useMemo(() => { const isMarkdown = (path: string | null | undefined) => !!path && /\.(md|markdown)(?:$|[?#])/i.test(path); @@ -210,31 +235,15 @@ export default function IframeChatPage() { externalContext?: Record, options?: { additionalKwargs?: Record }, ) => { - if ( - selectedAgentId && - isNewThread && - !hasStartedConversation && - message.text && - (!message.files || message.files.length === 0) - ) { - try { - sessionStorage.setItem( - "pendingAgentMessage", - JSON.stringify({ agentId: selectedAgentId, text: message.text }), - ); - } catch { - // sessionStorage may be unavailable in private mode; fall through. - } - router.push(`/page/workspace/agents/${selectedAgentId}/chats/new`); - return; - } - setHasStartedConversation(true); window.dispatchEvent( new CustomEvent("workspace:sandbox-resume-autoscroll"), ); const extraContext: Record = {}; + if (activeAssistantId) { + extraContext.agent_id = activeAssistantId; + } if (settings.context.writing_mode && writingArtifactPath) { extraContext.writing_artifact_path = writingArtifactPath; } @@ -250,10 +259,8 @@ export default function IframeChatPage() { ); }, [ + activeAssistantId, hasStartedConversation, - isNewThread, - router, - selectedAgentId, sendMessage, settings.context.writing_mode, threadId, @@ -277,6 +284,8 @@ export default function IframeChatPage() { const ragModeLocked = isThreadRagModeLocked(threadId); const ragModeEnabled = localSettings.display.ragModeEnabled || ragModeLocked; + const contextCompressionEnabled = + localSettings.display.contextCompressionEnabled ?? false; const proModeEnabled = settings.context.mode === "pro"; const toggleWritingMode = useCallback(() => { @@ -301,6 +310,12 @@ export default function IframeChatPage() { }); }, [ragModeEnabled, ragModeLocked, setLocalSettings]); + const toggleContextCompression = useCallback(() => { + setLocalSettings("display", { + contextCompressionEnabled: !contextCompressionEnabled, + }); + }, [contextCompressionEnabled, setLocalSettings]); + const toggleProMode = useCallback(() => { setSettings("context", { mode: proModeEnabled ? "flash" : "pro", @@ -333,28 +348,29 @@ export default function IframeChatPage() { const data = event.data; if (data?.source !== "brainy-extension") return; if (data?.type === "brainy:chat-interrupt") { - void handleInterrupt(data); + void handleInterrupt(data as BrainyChatInterruptMessage); return; } if (data?.type !== "brainy:chat-submit") return; + const submitMessage = data as BrainyChatSubmitMessage; const { submitText, files, iframeContext, additionalKwargs } = - parseBrainyChatSubmitMessage(data); + parseBrainyChatSubmitMessage(submitMessage); console.log("收到插件消息", { - text: data.payload?.text, - prompt: data.payload?.prompt, - pageContext: data.payload?.pageContext, - screenshot: data.payload?.screenshot, - model: data.payload?.model, - requestId: data.requestId, + text: submitMessage.payload?.text, + prompt: submitMessage.payload?.prompt, + pageContext: submitMessage.payload?.pageContext, + screenshot: submitMessage.payload?.screenshot, + model: submitMessage.payload?.model, + requestId: submitMessage.requestId, filesCount: files.length, iframeContext, }); if (!submitText && files.length === 0) return; - pendingRequestIdRef.current = data.requestId ?? null; + pendingRequestIdRef.current = submitMessage.requestId ?? null; handleSubmit( { text: submitText, files }, @@ -371,41 +387,59 @@ export default function IframeChatPage() { const isLandingState = isNewThread && !hasStartedConversation; const bottomBarHeightClass = "pb-24"; - const modeButtons = [ - { - key: "writing", - label: "写作", - icon: FileTextIcon, - active: !!settings.context.writing_mode, - onClick: toggleWritingMode, - activeClassName: "text-amber-600 hover:text-amber-700", - }, - { - key: "memory", - label: "记忆", - icon: BrainCircuitIcon, - active: settings.context.memory_injection_enabled ?? true, - onClick: toggleMemoryInjection, - activeClassName: "text-blue-600 hover:text-blue-700", - }, - { - key: "references", - label: "参考文献", - icon: BookMarkedIcon, - active: ragModeEnabled, - onClick: toggleRagMode, - disabled: ragModeLocked, - activeClassName: "text-emerald-600 hover:text-emerald-700", - }, - { - key: "pro", - label: "专业问答", - icon: GraduationCapIcon, - active: proModeEnabled, - onClick: toggleProMode, - activeClassName: "text-violet-600 hover:text-violet-700", - }, - ] as const; + const modeButtons = buildIframeModeButtons({ + agentSelected: !!selectedAgentId, + writingModeEnabled: !!settings.context.writing_mode, + memoryInjectionEnabled: Boolean( + settings.context.memory_injection_enabled ?? true, + ), + ragModeEnabled, + ragModeLocked, + contextCompressionEnabled, + proModeEnabled, + }).map((button) => { + switch (button.key) { + case "agent": + return button; + case "writing": + return { + ...button, + icon: FileTextIcon, + onClick: toggleWritingMode, + activeClassName: "text-amber-600 hover:text-amber-700", + }; + case "memory": + return { + ...button, + icon: BrainCircuitIcon, + onClick: toggleMemoryInjection, + activeClassName: "text-blue-600 hover:text-blue-700", + }; + case "references": + return { + ...button, + icon: BookMarkedIcon, + onClick: toggleRagMode, + activeClassName: "text-emerald-600 hover:text-emerald-700", + }; + case "contextCompression": + return { + ...button, + icon: ShrinkIcon, + onClick: toggleContextCompression, + activeClassName: "text-violet-600 hover:text-violet-700", + }; + case "pro": + return { + ...button, + icon: GraduationCapIcon, + onClick: toggleProMode, + activeClassName: "text-fuchsia-600 hover:text-fuchsia-700", + }; + default: + return button; + } + }); return ( @@ -496,6 +530,19 @@ export default function IframeChatPage() {
{modeButtons.map((button) => { + if (button.key === "agent") { + return ( + + ); + } const Icon = button.icon; return ( diff --git a/frontend-web/src/pages/iframe-chat-agent.test.ts b/frontend-web/src/pages/iframe-chat-agent.test.ts new file mode 100644 index 00000000..abeb1fb7 --- /dev/null +++ b/frontend-web/src/pages/iframe-chat-agent.test.ts @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +// @ts-ignore Node's test runner resolves the local .ts module directly. +import { resolveIframeAssistantId } from "./iframe-chat-agent.ts"; + +test("prefers the currently selected iframe agent", () => { + assert.equal( + resolveIframeAssistantId({ + selectedAgentId: "roundtable-structure", + threadMetadataAgentId: "lead_agent", + }), + "roundtable-structure", + ); +}); + +test("reuses the thread-bound agent when reopening an iframe thread", () => { + assert.equal( + resolveIframeAssistantId({ + selectedAgentId: null, + threadMetadataAgentId: "roundtable-structure", + }), + "roundtable-structure", + ); +}); + +test("falls back to lead-agent mode when no agent is selected or bound", () => { + assert.equal( + resolveIframeAssistantId({ + selectedAgentId: null, + threadMetadataAgentId: null, + }), + undefined, + ); +}); diff --git a/frontend-web/src/pages/iframe-chat-agent.ts b/frontend-web/src/pages/iframe-chat-agent.ts new file mode 100644 index 00000000..f10fdfa1 --- /dev/null +++ b/frontend-web/src/pages/iframe-chat-agent.ts @@ -0,0 +1,19 @@ +type ResolveIframeAssistantIdInput = { + selectedAgentId: string | null; + threadMetadataAgentId: string | null; +}; + +function normalizeAgentId(agentId: string | null | undefined): string | undefined { + const trimmed = agentId?.trim(); + return trimmed ? trimmed : undefined; +} + +export function resolveIframeAssistantId({ + selectedAgentId, + threadMetadataAgentId, +}: ResolveIframeAssistantIdInput): string | undefined { + return ( + normalizeAgentId(selectedAgentId) ?? + normalizeAgentId(threadMetadataAgentId) + ); +} diff --git a/frontend-web/src/pages/iframe-chat-mode-buttons.test.ts b/frontend-web/src/pages/iframe-chat-mode-buttons.test.ts new file mode 100644 index 00000000..6e33a752 --- /dev/null +++ b/frontend-web/src/pages/iframe-chat-mode-buttons.test.ts @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +// @ts-ignore Node's test runner resolves the local .ts module directly. +import { buildIframeModeButtons } from "./iframe-chat-mode-buttons.ts"; + +test("includes the context compression toggle in the iframe chat toolbar", () => { + const buttons = buildIframeModeButtons({ + agentSelected: false, + writingModeEnabled: false, + memoryInjectionEnabled: true, + ragModeEnabled: false, + ragModeLocked: false, + contextCompressionEnabled: false, + proModeEnabled: false, + }); + + assert.deepEqual( + buttons.map((button) => button.key), + [ + "writing", + "memory", + "references", + "contextCompression", + "pro", + "agent", + ], + ); +}); + +test("returns only concrete toolbar button configs", () => { + const buttons = buildIframeModeButtons({ + agentSelected: true, + writingModeEnabled: false, + memoryInjectionEnabled: true, + ragModeEnabled: false, + ragModeLocked: false, + contextCompressionEnabled: false, + proModeEnabled: false, + }); + + assert.equal(buttons.every((button) => typeof button.key === "string"), true); +}); diff --git a/frontend-web/src/pages/iframe-chat-mode-buttons.ts b/frontend-web/src/pages/iframe-chat-mode-buttons.ts new file mode 100644 index 00000000..47f3edd4 --- /dev/null +++ b/frontend-web/src/pages/iframe-chat-mode-buttons.ts @@ -0,0 +1,66 @@ +export type IframeModeButtonState = { + key: + | "agent" + | "writing" + | "memory" + | "references" + | "contextCompression" + | "pro"; + label: string; + active: boolean; + disabled?: boolean; +}; + +type BuildIframeModeButtonsInput = { + agentSelected: boolean; + writingModeEnabled: boolean; + memoryInjectionEnabled: boolean; + ragModeEnabled: boolean; + ragModeLocked: boolean; + contextCompressionEnabled: boolean; + proModeEnabled: boolean; +}; + +export function buildIframeModeButtons({ + agentSelected, + writingModeEnabled, + memoryInjectionEnabled, + ragModeEnabled, + ragModeLocked, + contextCompressionEnabled, + proModeEnabled, +}: BuildIframeModeButtonsInput): IframeModeButtonState[] { + return [ + { + key: "writing", + label: "写作", + active: writingModeEnabled, + }, + { + key: "memory", + label: "记忆", + active: memoryInjectionEnabled, + }, + { + key: "references", + label: "参考文献", + active: ragModeEnabled, + disabled: ragModeLocked, + }, + { + key: "contextCompression", + label: "上下文压缩", + active: contextCompressionEnabled, + }, + { + key: "pro", + label: "专业问答", + active: proModeEnabled, + }, + { + key: "agent", + label: "智能体", + active: agentSelected, + }, + ]; +} From 91a618375784bd3fe65c8be560f4fd8b53e99ea4 Mon Sep 17 00:00:00 2001 From: xuhd Date: Tue, 23 Jun 2026 11:27:40 +0800 Subject: [PATCH 4/8] =?UTF-8?q?feat(notebook):=20=E6=9D=A5=E6=BA=90?= =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E4=BC=98=E5=8C=96=20+=20=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E5=AE=A4=E9=97=AE=E7=AD=94=E5=81=9C=E6=AD=A2=E5=9B=9E=E7=AD=94?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 上传来源后显示处理中占位卡,轮询成功后自动选中该来源 - 失败时不选中,占位卡去重(已在真实列表中则不显示) - 输入框三态:空→无按钮,有内容→发送箭头,streaming→停止按钮 Co-Authored-By: Claude Sonnet 4.6 --- .../src/pages/StudioNotebookDetailPage.tsx | 125 +++++++++++++++--- 1 file changed, 104 insertions(+), 21 deletions(-) diff --git a/frontend-web/src/pages/StudioNotebookDetailPage.tsx b/frontend-web/src/pages/StudioNotebookDetailPage.tsx index 83d367f4..bfb7ea8b 100644 --- a/frontend-web/src/pages/StudioNotebookDetailPage.tsx +++ b/frontend-web/src/pages/StudioNotebookDetailPage.tsx @@ -6,11 +6,13 @@ import { AlertCircle, AlignLeft, ArrowLeft, + ArrowUp, BookOpen, Files, Globe, Loader2, Plus, + Square, X, } from "lucide-react"; @@ -119,6 +121,20 @@ function SourceCard({ ); } +// ── 上传中占位卡片 ────────────────────────────────────── +function PendingSourceCard({ title }: { title: string }) { + return ( +
+
+ +
+
+

{title}

+
+
+ ); +} + // ── 来源骨架屏 ────────────────────────────────────────── function SourceCardSkeleton() { return ( @@ -137,6 +153,7 @@ function SourcesDialog({ sources, isLoadingSources, selectedSourceIds, + pendingSources, onToggle, onDelete, }: { @@ -146,6 +163,7 @@ function SourcesDialog({ sources: SourceItem[]; isLoadingSources: boolean; selectedSourceIds: number[]; + pendingSources: Map; onToggle: (id: number) => void; onDelete: (id: number) => void; }) { @@ -189,15 +207,26 @@ function SourcesDialog({
{isLoadingSources ? Array.from({ length: 8 }).map((_, i) => ) - : sources.map((src) => ( - - ))} + : <> + {Array.from(pendingSources.entries()) + .filter(([, s]) => !sources.some((src) => { + const srcTitle = src.title ?? (src.document as { title?: string } | undefined)?.title ?? ""; + return srcTitle === s.title; + })) + .map(([taskId, s]) => ( + + ))} + {sources.map((src) => ( + + ))} + + }
{!isLoadingSources && sources.length === 0 && (

暂无来源,请添加文件。

@@ -231,6 +260,8 @@ export default function StudioNotebookDetailPage({ // null = 显示历史线程列表;string = 显示该线程的聊天 const [activeThreadId, setActiveThreadId] = useState(null); const [citationChunkId, setCitationChunkId] = useState(null); + // 轮询成功后待选中的来源标题集合(后端上传时不返回 id,等 sources 刷新后按标题匹配) + const pendingSelectTitlesRef = useRef>(new Set()); const [threadsExpanded, setThreadsExpanded] = useState(false); const THREADS_LIMIT = 5; const [citationDoc, setCitationDoc] = useState(null); @@ -255,6 +286,22 @@ export default function StudioNotebookDetailPage({ setSelectedSourceIds(sources.map((s) => s.id)); } }, [sources, notebook?.id, initializedForNotebook]); + + // sources 刷新后,将轮询成功的来源(按标题匹配)自动选中 + useEffect(() => { + if (pendingSelectTitlesRef.current.size === 0) return; + const matched = sources.filter((s) => { + const title = s.title ?? (s.document as { title?: string } | undefined)?.title ?? ""; + return pendingSelectTitlesRef.current.has(title); + }); + if (matched.length > 0) { + setSelectedSourceIds((prev) => [...new Set([...prev, ...matched.map((s) => s.id)])]); + matched.forEach((s) => { + const title = s.title ?? (s.document as { title?: string } | undefined)?.title ?? ""; + pendingSelectTitlesRef.current.delete(title); + }); + } + }, [sources]); const citationRehypePlugins = useMemo(() => { const mapped = sources.map((s) => ({ id: s.id, @@ -299,6 +346,10 @@ export default function StudioNotebookDetailPage({ } }; + const handleStop = useCallback(() => { + void thread.stop(); + }, [thread]); + const handleSend = async (e: React.SyntheticEvent) => { e.preventDefault(); const text = input.trim(); @@ -360,20 +411,17 @@ export default function StudioNotebookDetailPage({ const data = await response.json() as { status: 'processing' | 'success' | 'failed' | 'error' }; const taskStatus = data.status === 'failed' ? 'error' : data.status; if (taskStatus !== 'processing') { - // 任务完成,从轮询队列移除并刷新来源列表 + // 先取出 source 信息,再从轮询队列移除 + const source = pendingSources.get(taskId); setPendingSources((prev) => { const newMap = new Map(prev); - const source = newMap.get(taskId); newMap.delete(taskId); - if (source) { - if (taskStatus === 'success') { - // toast.success(`来源 "${source.title}" 处理完成`); - } else { - // toast.error(`来源 "${source.title}" 处理失败`); - } - } return newMap; }); + // 仅成功时记录标题,等 sources 刷新后按标题匹配 id 并选中 + if (taskStatus === 'success' && source?.title) { + pendingSelectTitlesRef.current.add(source.title); + } // 刷新来源列表 void queryClient.invalidateQueries({ queryKey: ["studio", "sources"] }); // 刷新 notebooks 数据以更新 source_count @@ -383,7 +431,7 @@ export default function StudioNotebookDetailPage({ } catch (error) { console.error(`轮询任务 ${taskId} 状态失败:`, error); } - }, [notebook?.id, queryClient]); + }, [notebook?.id, pendingSources, queryClient]); // 控制轮询 - 只在对话框打开时轮询 useEffect(() => { @@ -585,7 +633,6 @@ export default function StudioNotebookDetailPage({ disabled={isStreaming} className="flex-1 bg-transparent text-[15px] text-foreground placeholder:text-muted-foreground outline-none disabled:opacity-50" /> - {isStreaming && } {models.length > 0 && ( )} + {isStreaming ? ( + + ) : input.trim() ? ( + + ) : null} {/* 空状态提示 */} @@ -713,7 +778,6 @@ export default function StudioNotebookDetailPage({ disabled={isStreaming} className="flex-1 bg-transparent text-[15px] text-foreground placeholder:text-muted-foreground outline-none disabled:opacity-50" /> - {isStreaming && } {models.length > 0 && ( )} + {isStreaming ? ( + + ) : input.trim() ? ( + + ) : null}
@@ -771,6 +853,7 @@ export default function StudioNotebookDetailPage({ sources={sources} isLoadingSources={isLoadingSources} selectedSourceIds={selectedSourceIds} + pendingSources={pendingSources} onToggle={handleToggleSource} onDelete={handleDeleteSource} /> From d2be3b206d5aa8a6d8c762cb2979e00281bda763 Mon Sep 17 00:00:00 2001 From: Administrator Date: Tue, 23 Jun 2026 17:46:08 +0800 Subject: [PATCH 5/8] =?UTF-8?q?feat(y-log):=20=E6=93=8D=E4=BD=9C=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E4=B8=8A=E6=8A=A5=EF=BC=88=E5=89=8D=E7=AB=AF=E7=9B=B4?= =?UTF-8?q?=E8=BF=9E=20+=20=E5=90=8E=E7=AB=AF=E5=85=AC=E5=BC=80=E5=8F=AA?= =?UTF-8?q?=E8=AF=BB=E4=B8=8B=E5=8F=91=E5=9C=B0=E5=9D=80=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端新增 y_log 公开只读配置:YLogConfig + AppConfig.y_log + GET /api/public/config/y-log,地址写在 config.yaml(mtime 热重载) - 前端核心 src/core/log/:writeYLog(双门控/异步即发即忘/永不抛错/ 去ip-host/对象序列化裁剪/optResult恒0)+ 配置加载 + token1 隔离存取 - 登录(token)持久化 token1 并报登录、登出先报后清;启动预热配置 - 各 API client 成功路径埋点:敏感词/菜单/按钮/技能地址/轻应用、 会商草稿+任务草稿+后台任务、智能体/定时任务/知识库、Word导出, 以及问答完成钩子(ChatPage/AgentChatPage onFinish) Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 1 + frontend-web/src/App.tsx | 6 + .../settings/account-settings-page.tsx | 10 ++ frontend-web/src/core/agents/api.ts | 135 ++++++++++++++++-- .../src/core/api/markdown-docx-export.ts | 19 +++ frontend-web/src/core/knowledge/api.ts | 84 ++++++++++- frontend-web/src/core/log/y-log-config.ts | 68 +++++++++ frontend-web/src/core/log/y-log-token.ts | 34 +++++ frontend-web/src/core/log/y-log.ts | 109 ++++++++++++++ frontend-web/src/core/scheduled-tasks/api.ts | 81 +++++++++-- .../src/core/scheduled-tasks/markdown-docx.ts | 8 ++ frontend-web/src/pages/AgentChatPage.tsx | 16 +++ frontend-web/src/pages/ChatPage.tsx | 16 +++ frontend-web/src/pages/LoginPage.tsx | 16 +++ .../src/roundtable-planning/api/drafts.ts | 36 ++++- .../api/roundtable-jobs.ts | 21 ++- .../roundtable-planning/api/task-drafts.ts | 37 ++++- .../src/strategy-components/api/light-apps.ts | 37 ++++- .../strategy-components/api/menu-overrides.ts | 11 +- .../api/sensitive-words.ts | 20 ++- .../api/skill-addresses.ts | 35 ++++- .../strategy-components/api/task-buttons.ts | 11 +- .../app/gateway/routers/public_config.py | 7 + .../harness/deerflow/config/app_config.py | 2 + .../harness/deerflow/config/y_log_config.py | 20 +++ offline-backend-20260512/config.yaml | 8 ++ 26 files changed, 808 insertions(+), 40 deletions(-) create mode 100644 frontend-web/src/core/log/y-log-config.ts create mode 100644 frontend-web/src/core/log/y-log-token.ts create mode 100644 frontend-web/src/core/log/y-log.ts create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/config/y_log_config.py diff --git a/CLAUDE.md b/CLAUDE.md index 3a401e82..63365caf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -90,6 +90,7 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA: - **Shims**: `src/shims/` provides `next/link` and `next/navigation` polyfills (original code was Next.js) - **Login URL params** (`LoginPage.tsx`, frontend-only — the backend never sees these): besides the auth params (`ticket`/`userId`/`env`/`theme`/`yUserId`/`token`/`access_token`/`authToken`/`password`), the page supports a **task deep-link** via `?taskId=&goPath=`. After login it calls the consumer API `GET {VITE_AIPI_CONSUMER_URL}/taskAnalyseSearch/cop-task-detail?taskId=` (header `Authorization: Bearer admin`, auto-injected by `utils/request.ts`), stashes the full `data` (`id/overview/taskDirection/taskName/taskContent`) into `sessionStorage["login.copTaskDetail"]`, then routes by `goPath`: base codes `3qfx`/`rwfx`/`xdfx` → that business's configured **single agent** Q&A inside a **任务工作区** (resolved via `/api/business-mapping`; `resolveTaskDeeplinkTarget` calls `persistTaskContext({taskId, agentId, openingText})` and navigates to the `…/chats` **index**, which opens the latest task conversation or 新建 — a 1:N task↔对话 workspace, NOT `pendingAgentMessage`); the `-A` suffix (`3qfx-A`/…) → **multi-agent roundtable** with `roundtablePrefill.businessCode` so the page auto-selects the configured 业务链条. **xdfx's deep-link `?taskId=` is actually an 行动id, not a 任务id** — `resolveDeeplinkTaskId(goPath, rawId)` (called in `LoginPage` before everything) detects `xdfx`/`xdfx-A` and resolves it via `GET {action_detail_url}/{actionId}` → `data.taskId` (`fetchTaskIdFromAction`; `action_test`+`mock.action_detail` for offline testing); the resolved 任务id then drives `cop-task-detail`, the purpose tree, and the 1:N binding (other businesses pass the id through unchanged). **xdfx additionally partitions its chat records by 行动id (action_id), NOT by task_id**: `resolveTaskDeeplinkTarget` sets `partitionByAction:true` + keeps the raw action_id as `TaskContext.actionId`, so new threads are tagged `metadata.{taskId,actionId}` (taskId kept only to preserve the backend「任务深链对话全局共享」semantics, which keys off `metadata.taskId`) and the conversation list filters by `actionId` (+`agent_id`) — different actions of the same task get separate lists. xdfx's first-question opening text also appends `行动id(action_id)为…` (see `buildOpeningText` / `taskThreadMetadata`). goPath→businessCode: `3qfx`→`3Q`, `rwfx`→`6BF`, `xdfx`→`7BF`. The roundtable target also threads the URL `taskId` into `roundtablePrefill.taskId` (`resolveTaskDeeplinkTarget(goPath, detail, taskId)`), which turns on two taskId-scoped behaviors: (1) **separate, unpartitioned per-task storage** — taskId 会商聊天记录 live in their **own** store/table (`roundtable_task_drafts`), keyed **only by `task_id`** (one taskId = many records, shared across users, **no user 分权** at all; `created_by` is audit-only), kept entirely out of the per-user personal store (`roundtable_drafts`, now strictly per-user) so the two never mix. Backend router `/api/roundtable-task-drafts` (`GET /by-task/{id}` + `/by-task/{id}/latest`, `POST`, `GET/PUT/DELETE /{id}` — all user-agnostic); frontend client `roundtable-planning/api/task-drafts.ts`, and `useDraftPersistence`/`useRoundtableDrafts` route **all** CRUD to it when a `taskId` is present (else the personal `drafts.ts`). The matching **background jobs** are shared by task too: `roundtable_jobs.task_id` marks a task job; reads/dedup/stream/resume/cancel pass `?task_id=` so any user opening the task sees the same running 研讨 (progress/flowchart), and the executor writes the final report back to the task-draft store. Existing rows are moved over by `scripts/migrate_roundtable_task_drafts.py` (migration `20260618_01`). The history dropdown filters to the task and 新建 keeps the binding; (2) **Step-3 auto structured output** — after the `roundtable-summary` agent writes the Markdown report it automatically runs a second round emitting a `flow-json` flowchart (`useStep3Summary` `autoFlow`+`chainLevels`), whose hierarchy follows the business chain's stage goals (任务 → 目的 → 行为体 → …; coordinatorPrompt-specified hierarchy wins) and whose nodes never carry 阶段编号/阶段名 (the prior bug). **Both targets render embedded under the 任务管理 layout** — nested routes under `task-management//{roundtable,agent/:agent_id/chats}` in `StrategyRoutes.tsx` (`` = `plan-task-sentiment` / `plan-task/plan-task-fx` / `plan-task/plan-task-action`, matching the COH中任务 menu paths in `sidebar-menu.ts`) so the global sidebar keeps 任务管理 → COH中任务 → the matching business highlighted. **All three single-agent businesses share `TaskFxWorkspace`** (`task-components/TaskFxWorkspace.tsx`): left = a「当前任务」card (`readCopTaskDetail()` 任务名称 + 简介, desensitized, 留存 above 新建对话) + per-task 1:N conversation list (`useTaskThreads(taskId, agentId, actionId?)` — filtered by **both** `metadata.taskId` AND `metadata.agent_id` so the three businesses' conversations stay isolated even though they share one taskId; **xdfx** passes a third `actionId` so it filters by `metadata.actionId` too, partitioning chat records per 行动 — `useChatsBase` derives it from `TaskContext.partitionByAction`) + 新建对话; the `…/chats` index (`TaskFxIndexResolver`) opens the latest task thread or 新建. The right `:thread_id` host differs by business: **rwfx** → `TaskFxChat` (`AgentChatPage taskGate`) = full workspace (non-`lite`): 目的树闸门 panel + 清除记录 (placed under 新建对话); **xdfx** → also `TaskFxChat` (same 目的树闸门 query+display, same `purpose_detail_url`) but workspace passed `lite`; **3qfx** → `TaskBindChat` (`AgentChatPage taskBind`, `lite`) = 1:N list only, no gate, and 新建对话 **auto-sends** the task opening text (`taskAutoSendText`; gate businesses send it via the gate's 开始问答 instead). **rwfx auto-starts the first Q&A when the 任务目的 query returns empty** (`TaskPurposePanel autoStartWhenEmpty`, threaded from the rwfx `TaskFxChat` only). The 目的树闸门 (`TaskPurposePanel`) defaults **collapsed** and now keeps only 弹框查看 + 开始问答; the former jump buttons (编辑/下一步/3q详情/切换三情/弹框查看) all moved to the **configurable bottom 快捷跳转 bar** (`TaskJumpBar`, red 红底白字 buttons, right-aligned) driven by 按钮管理 — see the 按钮管理 bullet. `TaskPurposeDialog` (exported from `TaskPurposePanel`) backs both the gate zoom and the bottom bar's `purpose`-type button. `chatBasePath` keeps the post-send URL rewrite + new-chat button in the task-management context. Backend config `task_deeplink` (`config.yaml`, surfaced by `GET /api/public/config/task-deeplink`, mirrored in `core/auth/task-deeplink-config.ts`) adds `action_test`/`action_detail_url`/`detail_3q_url` + `mock.action_detail`. Any failure (no consumer URL / fetch error / unmapped goPath / unresolvable action id / no configured agent) silently falls back to the default home. Helpers: `core/auth/task-deeplink.ts`, `core/auth/task-purpose.ts`, `core/auth/task-deeplink-config.ts`, `core/threads/use-task-threads.ts`. - **iframe 嵌入模式 (外部系统用 iframe 套用深链问答/会商)** — 三个**按标签页**开关,全部由深链 URL 参数开启、存进同一份 `sessionStorage` 会话(`embed.session`,键名常量在 `core/embed/embed-session.ts`),跨登录跳转/刷新/页内导航存活,**绝不跨标签污染**同浏览器另开的正常系统页。核心隔离手法:用 `sessionStorage`(按标签页/浏览上下文隔离,iframe 即便同源也有独立一份)而非 `localStorage`(跨标签共享)。开关:(1) **`?embed=1`(可带 `&theme=`)** — 去全局 chrome(`PageLayout` 的 `pageSidebarAllowed && !isEmbedActive()` → 隐藏 Header + PageSidebar)+ 按外部主题色**强制**明/暗:走 next-themes 的 `forcedTheme`(`App.tsx` `AppProviders` 调 `resolveForcedEmbedTheme`,**不写** localStorage `strategy-theme`),离散映射 `dark-blue`/`dark`→深、其余→浅;嵌入侧**绝不**调 `setTheme`/`persistSidebarEnabled`(那俩落 localStorage 跨标签污染,`LoginPage` 据 `readEmbedParam` 跳过)。(2) **`?hideJump=1`** — 隐藏深链底部「快捷跳转」栏 `TaskJumpBar` 的两部分(底部「下一步」等按钮 + 右侧垂直居中悬浮菜单),单智能体问答 + 会商共用该栏,`isJumpBarHidden()` 为真时整条 `return null`。(3) **登录用户隔离** — 嵌入态下鉴权/身份键(`deerflow.auth` token、`userInfo`、`login.*`、`tools_token`、maxkey/userDetail token)全部经 `core/auth/scoped-storage.ts` 的 `authStorage()`(嵌入→sessionStorage / 正常→localStorage)读写,使 iframe 里登录的用户与正常登录用户**完全隔离**、互不覆盖;所有 token 读取都过 `getStoredAuth()`/`getAuthorizationHeaderValue()`(API 客户端 `core/api/*` 全走这俩),改中心函数即让全体调用方自动隔离;跨标签账号变更监听 `AuthSyncWatcher`(`core/auth/sync.tsx`)在嵌入态禁用。嵌入会话在 `main.tsx` 渲染前 `syncEmbedSessionFromCurrentUrl()` 同步启用(早于任何鉴权写入,避免首帧 effect 顺序漏写 localStorage);`AppProviders` 的 effect 再按 SPA 导航同步。`embed=0`/`hideJump=0` 可显式关闭。正常(非嵌入)标签页所有 `isXxx()` 恒 false,行为**零变化**。 +- **操作日志(Y-Log)上报** — 用户的关键操作(登录登出/查询/查看/增删改/启停/导入导出/问答等)成功后,**前端直连**外部接口 `POST {write_url}`(默认 `https://ch1.b.uat.4.cn/api/y-log/write`)记一条审计日志。核心模块 `src/core/log/`:`y-log.ts`(`writeYLog(params)` + `OptType` 枚举 0/1/3..12;**异步即发即忘、永不 await、永不抛错、失败静默**,自动把完整 url 转相对路径去 ip/host、对象入参/返回自动 `JSON.stringify` + 50k 裁剪、`optResult` 恒 0 只报成功),`y-log-config.ts`(从公开只读 `GET /api/public/config/y-log` 拉 `{enabled, writeUrl}`,模块级缓存 + 去重 + 默认兜底;`App.tsx` `AppProviders` 启动预热 `fetchYLogConfig()`),`y-log-token.ts`(token1 = token 登录的 `?authToken=`,经 [[scoped-storage]] 的 `authStorage*` 存取,嵌入态自动隔离)。**双门控**:仅当①存在 token1(即用 token 登录,`LoginPage` 成功后 `persistYLogToken(authToken)`,登出 `account-settings-page.tsx` 先报后清 token1)且②配置 `enabled && writeUrl` 才上报;否则一律不发。`optDetail` 统一「B1-CM助手-…」(**禁内部框架名**)。埋点集中在各 API client 的成功路径:`strategy-components/api/{sensitive-words,menu-overrides,task-buttons,skill-addresses,light-apps}.ts`、`roundtable-planning/api/{drafts,task-drafts,roundtable-jobs}.ts`、`core/{agents,scheduled-tasks,knowledge}/api.ts`、`core/api/markdown-docx-export.ts`,以及问答完成钩子(`ChatPage`/`AgentChatPage` 的 `useThreadStream` `onFinish`,用最终汇总文本作 `returnParam`,optType 12)。后端仅新增公开只读配置:`deerflow/config/y_log_config.py`(`YLogConfig{enabled,write_url}`)挂到 `AppConfig.y_log`,`app/gateway/routers/public_config.py` 加 `GET /api/public/config/y-log`,地址写在 `config.yaml` 的 `y_log` 段(mtime 热重载,前端无需重建);**不做后端代理**。Tests:`tests/test_y_log_config.py`。 ### Configuration diff --git a/frontend-web/src/App.tsx b/frontend-web/src/App.tsx index 2b9e6c9a..a4a67c48 100644 --- a/frontend-web/src/App.tsx +++ b/frontend-web/src/App.tsx @@ -7,6 +7,7 @@ import { FontScaleApplier } from "@/components/font-scale"; import { QueryClientProvider } from "@/components/query-client-provider"; import { AuthSyncWatcher } from "@/core/auth/sync"; import { I18nProvider } from "@/core/i18n/context"; +import { fetchYLogConfig } from "@/core/log/y-log-config"; import { detectLocale } from "@/core/i18n/locale"; import { resolveForcedThemeForPath } from "@/core/page-layout/chats-iframe-route"; import { ChatProvider } from "@/contexts/ChatContext"; @@ -50,6 +51,11 @@ function AppProviders({ children }: { children: React.ReactNode }) { useEffect(() => { syncEmbedSessionFromSearch(location.search); }, [location.search]); + // 操作日志(Y-Log):启动早期预热上报配置,使 writeYLog 同步读缓存即可工作 + // (刷新后 module 缓存清空,token 登录用户也能继续上报)。失败静默。 + useEffect(() => { + void fetchYLogConfig(); + }, []); const forcedTheme = useMemo(() => { // iframe 嵌入模式(外部系统 `&embed=1`)最高优先:按本标签页的嵌入主题强制明/暗。 // 走 next-themes forcedTheme(不写 localStorage)→ 同浏览器另开的正常系统页不受影响。 diff --git a/frontend-web/src/components/workspace/settings/account-settings-page.tsx b/frontend-web/src/components/workspace/settings/account-settings-page.tsx index e58bd82e..c8522df2 100644 --- a/frontend-web/src/components/workspace/settings/account-settings-page.tsx +++ b/frontend-web/src/components/workspace/settings/account-settings-page.tsx @@ -7,6 +7,8 @@ import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { clearStoredAuth, getAccountDisplayName, getStoredAuth } from "@/core/auth"; +import { OptType, writeYLog } from "@/core/log/y-log"; +import { clearYLogToken } from "@/core/log/y-log-token"; import { SettingsSection } from "./settings-section"; @@ -40,7 +42,15 @@ export function AccountSettingsPage() { const displayName = getAccountDisplayName(auth); const handleLogout = () => { + // 操作日志(Y-Log):在清除 token1 之前上报登出(此时 token1 还在),keepalive 保证发出。 + writeYLog({ + optType: OptType.logout, + optDetail: "B1-CM助手-登出", + url: "/api/v1/auth/logout", + keepalive: true, + }); clearStoredAuth(); + clearYLogToken(); // Match the rest of the app's logout flow: blow away the cookie session // by hitting the gateway logout endpoint and then forcing a full reload // so any cached query state goes too. diff --git a/frontend-web/src/core/agents/api.ts b/frontend-web/src/core/agents/api.ts index a580c48c..94766bd1 100644 --- a/frontend-web/src/core/agents/api.ts +++ b/frontend-web/src/core/agents/api.ts @@ -1,5 +1,6 @@ import { getBackendBaseURL } from "@/core/config"; import { apiFetch } from "@/core/api"; +import { OptType, writeYLog } from "@/core/log/y-log"; import type { Agent, @@ -58,6 +59,13 @@ export async function listAgents(params: ListAgentsParams = {}): Promise { @@ -91,6 +107,12 @@ export async function listAllAgentsAdmin(): Promise { throw new Error(err.detail ?? `Failed to load agents: ${res.statusText}`); } const data = (await res.json()) as { agents: Agent[] }; + writeYLog({ + optType: OptType.search, + optDetail: "B1-CM助手-智能体列表", + url: `/api/agents?admin=true`, + returnParam: data.agents, + }); return data.agents; } @@ -110,7 +132,15 @@ export async function createAgent(request: CreateAgentRequest): Promise { const err = (await res.json().catch(() => ({}))) as { detail?: string }; throw new Error(err.detail ?? `Failed to create agent: ${res.statusText}`); } - return res.json() as Promise; + const created = (await res.json()) as Agent; + writeYLog({ + optType: OptType.create, + optDetail: "B1-CM助手-智能体-新建", + url: `/api/agents`, + reqParam: request, + returnParam: created, + }); + return created; } export async function updateAgent( @@ -126,7 +156,15 @@ export async function updateAgent( const err = (await res.json().catch(() => ({}))) as { detail?: string }; throw new Error(err.detail ?? `Failed to update agent: ${res.statusText}`); } - return res.json() as Promise; + const updated = (await res.json()) as Agent; + writeYLog({ + optType: OptType.edit, + optDetail: "B1-CM助手-智能体-修改", + url: `/api/agents/${id}`, + reqParam: { id, ...request }, + returnParam: updated, + }); + return updated; } export async function deleteAgent(id: string): Promise { @@ -134,6 +172,12 @@ export async function deleteAgent(id: string): Promise { method: "DELETE", }); if (!res.ok) throw new Error(`Failed to delete agent: ${res.statusText}`); + writeYLog({ + optType: OptType.delete, + optDetail: "B1-CM助手-智能体-删除", + url: `/api/agents/${id}`, + reqParam: { id }, + }); } export async function getAgentSelector(): Promise { @@ -150,6 +194,12 @@ export async function favoriteAgent(id: string): Promise { const err = (await res.json().catch(() => ({}))) as { detail?: string }; throw new Error(err.detail ?? `Failed to favorite agent: ${res.statusText}`); } + writeYLog({ + optType: OptType.edit, + optDetail: "B1-CM助手-智能体-收藏", + url: `/api/agents/${id}/favorite`, + reqParam: { id }, + }); } export async function unfavoriteAgent(id: string): Promise { @@ -160,6 +210,12 @@ export async function unfavoriteAgent(id: string): Promise { const err = (await res.json().catch(() => ({}))) as { detail?: string }; throw new Error(err.detail ?? `Failed to unfavorite agent: ${res.statusText}`); } + writeYLog({ + optType: OptType.edit, + optDetail: "B1-CM助手-智能体-取消收藏", + url: `/api/agents/${id}/favorite`, + reqParam: { id }, + }); } export async function pinAgent(id: string): Promise { @@ -170,6 +226,12 @@ export async function pinAgent(id: string): Promise { const err = (await res.json().catch(() => ({}))) as { detail?: string }; throw new Error(err.detail ?? `Failed to pin agent: ${res.statusText}`); } + writeYLog({ + optType: OptType.edit, + optDetail: "B1-CM助手-智能体-置顶", + url: `/api/agents/${id}/pin`, + reqParam: { id }, + }); } export async function unpinAgent(id: string): Promise { @@ -180,6 +242,12 @@ export async function unpinAgent(id: string): Promise { const err = (await res.json().catch(() => ({}))) as { detail?: string }; throw new Error(err.detail ?? `Failed to unpin agent: ${res.statusText}`); } + writeYLog({ + optType: OptType.edit, + optDetail: "B1-CM助手-智能体-取消置顶", + url: `/api/agents/${id}/pin`, + reqParam: { id }, + }); } export async function reorderFeaturedAgents(agentIds: string[]): Promise { @@ -197,6 +265,12 @@ export async function reorderFeaturedAgents(agentIds: string[]): Promise { err.detail ?? `Failed to reorder featured agents: ${res.statusText}`, ); } + writeYLog({ + optType: OptType.edit, + optDetail: "B1-CM助手-智能体-精选排序", + url: `/api/agents/featured/order`, + reqParam: { agent_ids: agentIds }, + }); } export async function listPrivateAgents(password: string): Promise { @@ -215,6 +289,12 @@ export async function listPrivateAgents(password: string): Promise { ); } const data = (await res.json()) as { agents: Agent[] }; + writeYLog({ + optType: OptType.search, + optDetail: "B1-CM助手-私有广场智能体列表", + url: `/api/agents/private-square/list`, + returnParam: data.agents, + }); return data.agents; } @@ -261,12 +341,20 @@ export async function listPrivateAgentsPaginated( page?: number; page_size?: number; }; - return { + const result: PaginatedAgents = { agents: data.agents, total: data.total ?? data.agents.length, page: data.page ?? params.page ?? 1, pageSize: data.page_size ?? params.pageSize ?? data.agents.length, }; + writeYLog({ + optType: OptType.search, + optDetail: "B1-CM助手-私有广场智能体列表", + url: `/api/agents/private-square/list`, + reqParam: { ...params, password: undefined }, + returnParam: result, + }); + return result; } export async function setAgentPrivate( @@ -287,7 +375,15 @@ export async function setAgentPrivate( err.detail ?? `Failed to update agent privacy: ${res.statusText}`, ); } - return res.json() as Promise; + const updated = (await res.json()) as Agent; + writeYLog({ + optType: OptType.edit, + optDetail: "B1-CM助手-智能体-设置私有", + url: `/api/agents/${id}/private`, + reqParam: { id, is_private: isPrivate }, + returnParam: updated, + }); + return updated; } export async function setAgentFeatured( @@ -308,7 +404,15 @@ export async function setAgentFeatured( err.detail ?? `Failed to set featured: ${res.statusText}`, ); } - return res.json() as Promise; + const updated = (await res.json()) as Agent; + writeYLog({ + optType: OptType.edit, + optDetail: "B1-CM助手-智能体-设置精选", + url: `/api/agents/${id}/featured`, + reqParam: { id, order }, + returnParam: updated, + }); + return updated; } export interface AgentSkillsSyncRemoval { @@ -333,7 +437,14 @@ export async function adminSyncAgentSkills(): Promise { const err = (await res.json().catch(() => ({}))) as { detail?: string }; throw new Error(err.detail ?? `Failed to sync agent skills: ${res.statusText}`); } - return res.json() as Promise; + const result = (await res.json()) as AgentSkillsSyncResult; + writeYLog({ + optType: OptType.edit, + optDetail: "B1-CM助手-智能体-同步技能", + url: `/api/agents/admin/sync-skills`, + returnParam: result, + }); + return result; } export interface KnowledgeIngestAttempt { @@ -371,7 +482,15 @@ export async function publishArtifactToKnowledge(request: { const err = (await res.json().catch(() => ({}))) as { detail?: string }; throw new Error(err.detail ?? `发布入库失败: ${res.statusText}`); } - return res.json() as Promise; + const result = (await res.json()) as KnowledgeIngestPublishResult; + writeYLog({ + optType: OptType.export, + optDetail: "B1-CM助手-智能体-产物发布入库", + url: `/api/knowledge-ingest/publish`, + reqParam: request, + returnParam: result, + }); + return result; } export async function checkAgentName( diff --git a/frontend-web/src/core/api/markdown-docx-export.ts b/frontend-web/src/core/api/markdown-docx-export.ts index 2c7168b0..1225b755 100644 --- a/frontend-web/src/core/api/markdown-docx-export.ts +++ b/frontend-web/src/core/api/markdown-docx-export.ts @@ -1,5 +1,6 @@ import { apiFetch } from "@/core/api/fetch-client"; import { getBackendBaseURL } from "@/core/config"; +import { OptType, writeYLog } from "@/core/log/y-log"; import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx"; function filenameFromContentDisposition(header: string | null): string | null { @@ -79,13 +80,31 @@ export async function downloadMarkdownDocxFromBackend(options: { if (response.status === 404) { await downloadMarkdownAsDocx(markdown, options.title?.trim() || "document", options.title); + writeYLog({ + optType: OptType.export, + optDetail: "B1-CM助手-导出-Word", + url: `${getBackendBaseURL()}/api/writing/export/docx`, + reqParam: { title: options.title?.trim() || undefined }, + }); return; } await downloadBlobResponse(response, fallbackFilename); + writeYLog({ + optType: OptType.export, + optDetail: "B1-CM助手-导出-Word", + url: `${getBackendBaseURL()}/api/writing/export/docx`, + reqParam: { title: options.title?.trim() || undefined }, + }); } catch (err) { if (err instanceof TypeError) { await downloadMarkdownAsDocx(markdown, options.title?.trim() || "document", options.title); + writeYLog({ + optType: OptType.export, + optDetail: "B1-CM助手-导出-Word", + url: `${getBackendBaseURL()}/api/writing/export/docx`, + reqParam: { title: options.title?.trim() || undefined }, + }); return; } throw err; diff --git a/frontend-web/src/core/knowledge/api.ts b/frontend-web/src/core/knowledge/api.ts index 32968ecf..5439a561 100644 --- a/frontend-web/src/core/knowledge/api.ts +++ b/frontend-web/src/core/knowledge/api.ts @@ -1,5 +1,6 @@ import { apiFetch } from "@/core/api"; import { getBackendBaseURL } from "@/core/config"; +import { OptType, writeYLog } from "@/core/log/y-log"; import type { BatchIngestPayload, @@ -41,9 +42,17 @@ export async function listKnowledgeNotes(params: KnowledgeListParams = {}) { if (params.folder !== undefined) qs.set("folder", params.folder); qs.set("limit", String(params.limit ?? 20)); qs.set("offset", String(params.offset ?? 0)); - return parseResponse( + const result = await parseResponse( await apiFetch(`${baseUrl()}/notes?${qs.toString()}`), ); + writeYLog({ + optType: OptType.search, + optDetail: "B1-CM助手-知识库-检索", + url: `${baseUrl()}/notes?${qs.toString()}`, + reqParam: params, + returnParam: result, + }); + return result; } export async function getKnowledgeNote(noteId: string) { @@ -67,6 +76,13 @@ export async function createKnowledgeNote(data: { body: JSON.stringify(data), }), ); + writeYLog({ + optType: OptType.create, + optDetail: "B1-CM助手-知识库-新建", + url: `${baseUrl()}/notes`, + reqParam: data, + returnParam: res.note, + }); return res.note; } @@ -81,22 +97,38 @@ export async function updateKnowledgeNote( folder: string | null; }>, ) { - return parseResponse( + const result = await parseResponse( await apiFetch(`${baseUrl()}/notes/${encodeURIComponent(noteId)}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data), }), ); + writeYLog({ + optType: OptType.edit, + optDetail: "B1-CM助手-知识库-修改", + url: `${baseUrl()}/notes/${encodeURIComponent(noteId)}`, + reqParam: { noteId, data }, + returnParam: result, + }); + return result; } export async function deleteKnowledgeNote(noteId: string, hard = false) { - return parseResponse<{ ok: boolean; note_id: string }>( + const result = await parseResponse<{ ok: boolean; note_id: string }>( await apiFetch( `${baseUrl()}/notes/${encodeURIComponent(noteId)}${hard ? "?hard=true" : ""}`, { method: "DELETE" }, ), ); + writeYLog({ + optType: OptType.delete, + optDetail: "B1-CM助手-知识库-删除", + url: `${baseUrl()}/notes/${encodeURIComponent(noteId)}${hard ? "?hard=true" : ""}`, + reqParam: { noteId, hard }, + returnParam: result, + }); + return result; } export async function searchKnowledge(query: string, limit = 8) { @@ -107,11 +139,18 @@ export async function searchKnowledge(query: string, limit = 8) { body: JSON.stringify({ query, mode: "keyword", limit }), }), ); + writeYLog({ + optType: OptType.search, + optDetail: "B1-CM助手-知识库-检索", + url: `${baseUrl()}/search`, + reqParam: { query, mode: "keyword", limit }, + returnParam: res.items, + }); return res.items; } export async function ingestThread(threadId: string, payload: IngestThreadPayload = {}) { - return parseResponse<{ + const result = await parseResponse<{ note?: KnowledgeNote; created?: boolean; queued?: boolean; @@ -122,16 +161,32 @@ export async function ingestThread(threadId: string, payload: IngestThreadPayloa body: JSON.stringify(payload), }), ); + writeYLog({ + optType: OptType.import, + optDetail: "B1-CM助手-知识库-导入", + url: `${baseUrl()}/ingest/thread/${encodeURIComponent(threadId)}`, + reqParam: { threadId, payload }, + returnParam: result, + }); + return result; } export async function ingestThreadsBatch(payload: BatchIngestPayload = {}) { - return parseResponse( + const result = await parseResponse( await apiFetch(`${baseUrl()}/ingest/threads/batch`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }), ); + writeYLog({ + optType: OptType.import, + optDetail: "B1-CM助手-知识库-导入", + url: `${baseUrl()}/ingest/threads/batch`, + reqParam: payload, + returnParam: result, + }); + return result; } export interface IngestFileResult { @@ -162,9 +217,17 @@ export async function ingestKnowledgeFile( if (opts.template_id) form.append("template_id", opts.template_id); if (opts.folder) form.append("folder", opts.folder); // No Content-Type header — the browser sets the multipart boundary itself. - return parseResponse( + const result = await parseResponse( await apiFetch(`${baseUrl()}/ingest/file`, { method: "POST", body: form }), ); + writeYLog({ + optType: OptType.import, + optDetail: "B1-CM助手-知识库-导入", + url: `${baseUrl()}/ingest/file`, + reqParam: { fileName: file.name, ...opts }, + returnParam: result, + }); + return result; } /** Resolve `[[wikilink]]` targets to note ids / vault pages. */ @@ -197,9 +260,16 @@ export async function fetchVaultPage(path: string) { } export async function exportKnowledgeGraph() { - return parseResponse( + const result = await parseResponse( await apiFetch(`${baseUrl()}/export/graph`, { method: "POST" }), ); + writeYLog({ + optType: OptType.export, + optDetail: "B1-CM助手-知识库-导出", + url: `${baseUrl()}/export/graph`, + returnParam: result, + }); + return result; } /** Absolute URL of a served wiki-export file (for iframe / download). */ diff --git a/frontend-web/src/core/log/y-log-config.ts b/frontend-web/src/core/log/y-log-config.ts new file mode 100644 index 00000000..d0bcc0af --- /dev/null +++ b/frontend-web/src/core/log/y-log-config.ts @@ -0,0 +1,68 @@ +/** + * 操作日志(Y-Log)上报的运行时配置:从后端公开只读接口 + * ``GET /api/public/config/y-log`` 读取(地址 + 开关在后端 ``config.yaml`` 的 + * ``y_log`` 段,改完即时生效,前端无需重建)。 + * + * 仿 [[task-deeplink-config]]: + * - ``fetchYLogConfig()`` 拉取并模块级缓存(启动早期预热一次即可)。 + * - ``getCachedYLogConfig()`` 同步读缓存(``writeYLog`` 即发即忘,必须同步可用)。 + * + * 拉取失败 → 用内置默认值兜底(``enabled:false``,但保留默认地址),绝不抛错。 + */ + +import { apiFetch } from "@/core/api/fetch-client"; +import { getBackendBaseURL } from "@/core/config"; + +export interface YLogConfig { + /** 是否启用上报。 */ + enabled: boolean; + /** 上报外部接口完整地址(POST)。 */ + writeUrl: string; +} + +/** 内置默认地址(后端拿不到 / 未配置时的兜底地址;enabled 默认 false 不上报)。 */ +const DEFAULT_WRITE_URL = "https://ch1.b.uat.4.cn/api/y-log/write"; + +/** 内置默认配置(拉取失败兜底;默认不上报)。 */ +export const DEFAULT_Y_LOG_CONFIG: YLogConfig = { + enabled: false, + writeUrl: DEFAULT_WRITE_URL, +}; + +/** 后端 snake_case 响应 → 前端 camelCase;缺字段回退默认值。 */ +function normalize(body: unknown): YLogConfig { + const o = body && typeof body === "object" ? (body as Record) : {}; + const writeUrl = + typeof o.write_url === "string" && o.write_url.trim() ? o.write_url.trim() : DEFAULT_WRITE_URL; + return { + enabled: o.enabled === true, + writeUrl, + }; +} + +let cached: YLogConfig | null = null; +let inflight: Promise | null = null; + +/** 拉取(带模块级缓存 + 并发去重);失败回退默认值。 */ +export async function fetchYLogConfig(): Promise { + if (cached) return cached; + if (inflight) return inflight; + inflight = (async () => { + try { + const res = await apiFetch(`${getBackendBaseURL()}/api/public/config/y-log`); + if (!res.ok) throw new Error(`y-log config ${res.status}`); + cached = normalize(await res.json()); + } catch { + cached = { ...DEFAULT_Y_LOG_CONFIG }; + } finally { + inflight = null; + } + return cached; + })(); + return inflight; +} + +/** 同步读缓存(未拉取过则返回默认值,默认 enabled:false 不上报)。 */ +export function getCachedYLogConfig(): YLogConfig { + return cached ?? DEFAULT_Y_LOG_CONFIG; +} diff --git a/frontend-web/src/core/log/y-log-token.ts b/frontend-web/src/core/log/y-log-token.ts new file mode 100644 index 00000000..e24015c5 --- /dev/null +++ b/frontend-web/src/core/log/y-log-token.ts @@ -0,0 +1,34 @@ +/** + * token1(token 登录用的上游 token,即 URL 参数 ``?authToken=``)的存取。 + * + * 复用 [[scoped-storage]] 的 ``authStorage*``:嵌入(iframe)态自动落 sessionStorage、 + * 正常态落 localStorage,使 iframe 里登录的用户 token1 与正常标签页**完全隔离**、 + * 互不污染(与其它鉴权键同一隔离语义)。 + * + * 只有用 token 登录(存在 token1)才上报操作日志;非 token 登录不写 token1, + * 该会话从此不上报(见 [[y-log]])。 + */ + +import { + authStorageGet, + authStorageRemove, + authStorageSet, +} from "@/core/auth/scoped-storage"; + +const Y_LOG_TOKEN_KEY = "ylog.token1"; + +/** 持久化 token1(token 登录成功后调用;空值忽略)。 */ +export function persistYLogToken(token: string | null | undefined): void { + const t = token?.trim(); + if (t) authStorageSet(Y_LOG_TOKEN_KEY, t); +} + +/** 读 token1(无则返回 null → 不上报)。 */ +export function readYLogToken(): string | null { + return authStorageGet(Y_LOG_TOKEN_KEY); +} + +/** 清除 token1(登出时调用)。 */ +export function clearYLogToken(): void { + authStorageRemove(Y_LOG_TOKEN_KEY); +} diff --git a/frontend-web/src/core/log/y-log.ts b/frontend-web/src/core/log/y-log.ts new file mode 100644 index 00000000..c82ef5d0 --- /dev/null +++ b/frontend-web/src/core/log/y-log.ts @@ -0,0 +1,109 @@ +/** + * 操作日志(Y-Log)上报主函数。 + * + * 把用户的关键操作(点击/查询/查看/增删改/启停/导入导出/登录登出/问答等)上报到 + * 外部日志接口(见 [[y-log-config]] 的 ``writeUrl``)。**前端直连**该接口,不经后端代理。 + * + * 硬性约束(务必满足): + * ① **token 门控**:无 token1(非 token 登录)→ 直接 return,不发请求。 + * ② **配置门控**:``enabled=false`` 或 ``writeUrl`` 为空 → 不发请求。 + * ③ **异步即发即忘**:``void fetch(...)`` 不被 ``await``,绝不阻塞业务/UI。 + * ④ **永不抛错**:整体包在 ``try/catch`` 中,任何异常(取 token/读配置/序列化/网络)静默吞掉。 + * ⑤ **只报成功**:埋点只放在操作成功路径,``optResult`` 恒为 ``0``。 + * ⑥ **失败静默**:``fetch`` 的 ``.catch`` 吞掉网络/跨域失败。 + */ + +import { getCachedYLogConfig } from "@/core/log/y-log-config"; +import { readYLogToken } from "@/core/log/y-log-token"; + +/** 操作类型,数值与外部系统约定一致,不可改。 */ +export enum OptType { + login = 0, + logout = 1, + search = 3, + view = 4, + edit = 5, + delete = 6, + import = 7, + export = 8, + create = 9, + disabled = 10, + enabled = 11, + other = 12, +} + +export interface WriteYLogParams { + /** 操作类型枚举。 */ + optType: OptType; + /** 操作详情(简短描述),统一「B1-CM助手-xxx」。 */ + optDetail: string; + /** 操作命中的接口地址(完整或相对,内部自动转相对,去 ip/host)。 */ + url: string; + /** 入参(对象自动 JSON 序列化)。 */ + reqParam?: unknown; + /** 返回参数(对象自动 JSON 序列化)。 */ + returnParam?: unknown; + /** 登出 / 页面卸载等场景置 true,保证请求在卸载时仍能发出。 */ + keepalive?: boolean; +} + +/** 入参/返回体积上限,超出裁剪。 */ +const MAX_PARAM_LEN = 50_000; + +/** 上报一条操作日志(异步即发即忘,永不抛错)。 */ +export function writeYLog(params: WriteYLogParams): void { + try { + const token = readYLogToken(); + if (!token) return; // ① 无 token1 不上报 + const { enabled, writeUrl } = getCachedYLogConfig(); + if (!enabled || !writeUrl) return; // ② 配置门控 + if (typeof fetch === "undefined") return; + + const body = { + optType: params.optType, + optDetail: params.optDetail ?? "", + url: toRelativeUrl(params.url ?? ""), // ③ 去 ip/host + reqParam: toParamString(params.reqParam), // ④ 对象→字符串+裁剪 + returnParam: toParamString(params.returnParam), + optResult: 0, // ⑤ 恒为 0(只报成功) + }; + + void fetch(writeUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(body), + keepalive: params.keepalive ?? false, + }).catch(() => undefined); // ⑥ 失败静默 + } catch { + /* 绝不影响业务 */ + } +} + +function toParamString(v: unknown): string { + if (v == null) return ""; + const s = typeof v === "string" ? v : safeStringify(v); + return s.length > MAX_PARAM_LEN ? `${s.slice(0, MAX_PARAM_LEN)}…[truncated]` : s; +} + +function safeStringify(v: unknown): string { + try { + return JSON.stringify(v) ?? ""; + } catch { + return String(v); + } +} + +/** 去掉 ip/host,只留 path + query。 */ +function toRelativeUrl(url: string): string { + if (!url) return ""; + try { + const base = typeof window !== "undefined" ? window.location.origin : "http://localhost"; + const u = new URL(url, base); + return `${u.pathname}${u.search}`; + } catch { + return url; + } +} diff --git a/frontend-web/src/core/scheduled-tasks/api.ts b/frontend-web/src/core/scheduled-tasks/api.ts index c7b1a3af..846d5ffa 100644 --- a/frontend-web/src/core/scheduled-tasks/api.ts +++ b/frontend-web/src/core/scheduled-tasks/api.ts @@ -1,5 +1,6 @@ import { apiFetch } from "@/core/api"; import { getBackendBaseURL } from "@/core/config"; +import { OptType, writeYLog } from "@/core/log/y-log"; import type { HtmlPageFavorite, @@ -42,9 +43,17 @@ export async function listScheduledTasks(params: ListScheduledTasksParams = {}) if (params.search) qs.set("search", params.search); for (const id of params.tagIds ?? []) qs.append("tag_ids", id); const query = qs.toString(); - return parseResponse( + const result = await parseResponse( await apiFetch(`${baseUrl()}${query ? `?${query}` : ""}`), ); + writeYLog({ + optType: OptType.search, + optDetail: "B1-CM助手-定时任务-列表", + url: `${baseUrl()}${query ? `?${query}` : ""}`, + reqParam: params, + returnParam: result, + }); + return result; } export async function createScheduledTask(data: { @@ -57,13 +66,21 @@ export async function createScheduledTask(data: { require_markdown?: boolean; execution_context?: ScheduledTaskExecutionContext | null; }) { - return parseResponse( + const result = await parseResponse( await apiFetch(baseUrl(), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data), }), ); + writeYLog({ + optType: OptType.create, + optDetail: "B1-CM助手-定时任务-新建", + url: baseUrl(), + reqParam: data, + returnParam: result, + }); + return result; } export async function listScheduledTaskRuns(taskId: string) { @@ -73,9 +90,17 @@ export async function listScheduledTaskRuns(taskId: string) { } export async function getScheduledTaskRun(runId: string) { - return parseResponse( + const result = await parseResponse( await apiFetch(`${baseUrl()}/runs/${encodeURIComponent(runId)}`), ); + writeYLog({ + optType: OptType.view, + optDetail: "B1-CM助手-定时任务-运行详情", + url: `${baseUrl()}/runs/${encodeURIComponent(runId)}`, + reqParam: { runId }, + returnParam: result, + }); + return result; } export async function deleteScheduledTaskRun(runId: string) { @@ -219,45 +244,85 @@ export async function updateScheduledTask( execution_context: ScheduledTaskExecutionContext | null; }>, ) { - return parseResponse( + const result = await parseResponse( await apiFetch(`${baseUrl()}/${encodeURIComponent(taskId)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data), }), ); + writeYLog({ + optType: OptType.edit, + optDetail: "B1-CM助手-定时任务-修改", + url: `${baseUrl()}/${encodeURIComponent(taskId)}`, + reqParam: { taskId, data }, + returnParam: result, + }); + return result; } export async function deleteScheduledTask(taskId: string) { - return parseResponse<{ success: boolean }>( + const result = await parseResponse<{ success: boolean }>( await apiFetch(`${baseUrl()}/${encodeURIComponent(taskId)}`, { method: "DELETE", }), ); + writeYLog({ + optType: OptType.delete, + optDetail: "B1-CM助手-定时任务-删除", + url: `${baseUrl()}/${encodeURIComponent(taskId)}`, + reqParam: { taskId }, + returnParam: result, + }); + return result; } export async function pauseScheduledTask(taskId: string) { - return parseResponse( + const result = await parseResponse( await apiFetch(`${baseUrl()}/${encodeURIComponent(taskId)}/pause`, { method: "POST", }), ); + writeYLog({ + optType: OptType.disabled, + optDetail: "B1-CM助手-定时任务-暂停", + url: `${baseUrl()}/${encodeURIComponent(taskId)}/pause`, + reqParam: { taskId }, + returnParam: result, + }); + return result; } export async function resumeScheduledTask(taskId: string) { - return parseResponse( + const result = await parseResponse( await apiFetch(`${baseUrl()}/${encodeURIComponent(taskId)}/resume`, { method: "POST", }), ); + writeYLog({ + optType: OptType.enabled, + optDetail: "B1-CM助手-定时任务-恢复", + url: `${baseUrl()}/${encodeURIComponent(taskId)}/resume`, + reqParam: { taskId }, + returnParam: result, + }); + return result; } export async function triggerScheduledTask(taskId: string) { - return parseResponse<{ success: boolean; task: ScheduledTask }>( + const result = await parseResponse<{ success: boolean; task: ScheduledTask }>( await apiFetch(`${baseUrl()}/${encodeURIComponent(taskId)}/trigger`, { method: "POST", }), ); + writeYLog({ + optType: OptType.other, + optDetail: "B1-CM助手-定时任务-手动运行", + url: `${baseUrl()}/${encodeURIComponent(taskId)}/trigger`, + reqParam: { taskId }, + returnParam: result, + }); + return result; } export async function listPublishedScheduledTasks(params: { limit?: number; offset?: number } = {}) { diff --git a/frontend-web/src/core/scheduled-tasks/markdown-docx.ts b/frontend-web/src/core/scheduled-tasks/markdown-docx.ts index c3ae3028..025836c4 100644 --- a/frontend-web/src/core/scheduled-tasks/markdown-docx.ts +++ b/frontend-web/src/core/scheduled-tasks/markdown-docx.ts @@ -42,6 +42,8 @@ import { type IRunOptions, } from "docx"; +import { OptType, writeYLog } from "@/core/log/y-log"; + const BLACK = "000000"; const BODY_FONT = { ascii: "Calibri", eastAsia: "Microsoft YaHei" }; const MONO_FONT = { ascii: "Consolas", eastAsia: "Microsoft YaHei" }; @@ -484,6 +486,12 @@ export async function downloadMarkdownAsDocx( title?: string, ): Promise { const blob = await markdownToDocxBlob(markdown, title); + writeYLog({ + optType: OptType.export, + optDetail: "B1-CM助手-定时任务-导出", + url: "/scheduled-tasks/export/docx", + reqParam: { fileName, title }, + }); const safeName = fileName.replace(/\.(md|markdown)$/i, "") || "document"; const url = URL.createObjectURL(blob); const anchor = document.createElement("a"); diff --git a/frontend-web/src/pages/AgentChatPage.tsx b/frontend-web/src/pages/AgentChatPage.tsx index e59f7e09..e3cfe6c0 100644 --- a/frontend-web/src/pages/AgentChatPage.tsx +++ b/frontend-web/src/pages/AgentChatPage.tsx @@ -30,6 +30,7 @@ import { useNotification } from "@/core/notification/hooks"; import { saveLastAgentId, useThreadSettings } from "@/core/settings"; import { stopThreadRun, useThreadStream } from "@/core/threads/hooks"; import { textOfMessage } from "@/core/threads/utils"; +import { OptType, writeYLog } from "@/core/log/y-log"; import { env } from "@/env"; import { readTaskContext, taskThreadMetadata } from "@/core/auth/task-purpose"; import { TaskPurposePanel } from "@/strategy-components/components/task-components/TaskPurposePanel"; @@ -157,6 +158,21 @@ export default function AgentChatPage({ ); }, onFinish: (state) => { + // 操作日志(Y-Log):专家代理问答完成(成功)后,用最终汇总文本上报一条。 + try { + const msgs = state.messages ?? []; + const lastAi = [...msgs].reverse().find((m) => (m as { type?: string }).type === "ai"); + const lastHuman = [...msgs].reverse().find((m) => (m as { type?: string }).type === "human"); + writeYLog({ + optType: OptType.other, + optDetail: "B1-CM助手-专家代理问答", + url: `/api/threads/${threadId ?? ""}/runs/stream`, + reqParam: { agent_id, question: lastHuman ? textOfMessage(lastHuman) : "" }, + returnParam: { data: lastAi ? textOfMessage(lastAi) : "" }, + }); + } catch { + /* 上报绝不影响问答 */ + } if (document.hidden || !document.hasFocus()) { let body = "Conversation finished"; const lastMessage = state.messages[state.messages.length - 1]; diff --git a/frontend-web/src/pages/ChatPage.tsx b/frontend-web/src/pages/ChatPage.tsx index b1c24df3..fa284699 100644 --- a/frontend-web/src/pages/ChatPage.tsx +++ b/frontend-web/src/pages/ChatPage.tsx @@ -49,6 +49,7 @@ import { useThreadStream } from "@/core/threads/hooks"; import { useLayoutMode } from "@/core/system-settings/hooks"; import { useRouteBase } from "@/core/route-base"; import { textOfMessage } from "@/core/threads/utils"; +import { OptType, writeYLog } from "@/core/log/y-log"; import { env } from "@/env"; import { cn } from "@/lib/utils"; @@ -183,6 +184,21 @@ export default function ChatPage() { ); }, onFinish: (state) => { + // 操作日志(Y-Log):问答完成(成功)后,用最终汇总文本上报一条。 + try { + const msgs = state.messages ?? []; + const lastAi = [...msgs].reverse().find((m) => (m as { type?: string }).type === "ai"); + const lastHuman = [...msgs].reverse().find((m) => (m as { type?: string }).type === "human"); + writeYLog({ + optType: OptType.other, + optDetail: "B1-CM助手-专家代理问答", + url: `/api/threads/${threadId ?? ""}/runs/stream`, + reqParam: { agent_id: selectedAgentId, question: lastHuman ? textOfMessage(lastHuman) : "" }, + returnParam: { data: lastAi ? textOfMessage(lastAi) : "" }, + }); + } catch { + /* 上报绝不影响问答 */ + } if (document.hidden || !document.hasFocus()) { let body = "Conversation finished"; const lastMessage = state.messages.at(-1); diff --git a/frontend-web/src/pages/LoginPage.tsx b/frontend-web/src/pages/LoginPage.tsx index 6fc329d3..8aa6a45b 100644 --- a/frontend-web/src/pages/LoginPage.tsx +++ b/frontend-web/src/pages/LoginPage.tsx @@ -31,6 +31,9 @@ import { isSidebarEnabledFromSearch, persistSidebarEnabled, } from '@/core/page-layout/sidebar-enabled'; +import { fetchYLogConfig } from '@/core/log/y-log-config'; +import { persistYLogToken } from '@/core/log/y-log-token'; +import { OptType, writeYLog } from '@/core/log/y-log'; const DEFAULT_USERNAME = 'guest'; const IFRAME_LOGIN_SOURCE = 'iframe'; @@ -160,6 +163,19 @@ export default function LoginPage() { return; } setStoredAuth(auth); + // 操作日志(Y-Log):只有 token 登录(存在 token1=authToken)才上报。 + // 持久化 token1(走 authStorage,嵌入态自动隔离)+ 预热上报配置 + 上报登录。 + if (authToken) { + persistYLogToken(authToken); + void fetchYLogConfig(); + writeYLog({ + optType: OptType.login, + optDetail: 'B1-CM助手-登录', + url: '/api/v1/auth/login/token', + reqParam: { username: loginUserName || auth.user_id }, + returnParam: { user_id: auth.user_id }, + }); + } // 标准登录把用户名写入 userInfo,供顶栏用户区显示(已有则不覆盖) setStoredUserName(loginUserName); // 内网透传的 access_token 合并进 userInfo(在 loginByYUserId 覆盖之后), diff --git a/frontend-web/src/roundtable-planning/api/drafts.ts b/frontend-web/src/roundtable-planning/api/drafts.ts index 7641baba..88513d5c 100644 --- a/frontend-web/src/roundtable-planning/api/drafts.ts +++ b/frontend-web/src/roundtable-planning/api/drafts.ts @@ -14,6 +14,7 @@ import { apiFetch } from "@/core/api/fetch-client"; import { getBackendBaseURL } from "@/core/config"; +import { OptType, writeYLog } from "@/core/log/y-log"; import type { IntentSummary } from "@/roundtable-planning/api/intent"; import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent"; @@ -314,7 +315,14 @@ export function fromUpdate(p: DraftUpdatePayload) { export async function listDrafts(): Promise { const res = await apiFetch(BASE(), { method: "GET" }); const data = await readJson<{ drafts: ServerDraftMeta[] }>(res, "list drafts"); - return (data.drafts ?? []).map(toMeta); + const metas = (data.drafts ?? []).map(toMeta); + writeYLog({ + optType: OptType.search, + optDetail: "B1-CM助手-会商-查询草稿", + url: BASE(), + returnParam: { count: metas.length }, + }); + return metas; } /** Fetch the full draft (incl. step1/step2). Returns null on 404. */ @@ -332,7 +340,15 @@ export async function createDraft(payload: DraftCreatePayload): Promise(res, "create draft"); - return toRecord(data); + const record = toRecord(data); + writeYLog({ + optType: OptType.create, + optDetail: "B1-CM助手-会商-新建草稿", + url: BASE(), + reqParam: { title: payload.title, furthestStep: payload.furthestStep, taskId: payload.taskId ?? null }, + returnParam: { id: record.id, title: record.title }, + }); + return record; } export async function updateDraft(id: string, patch: DraftUpdatePayload): Promise { @@ -343,7 +359,15 @@ export async function updateDraft(id: string, patch: DraftUpdatePayload): Promis }); if (res.status === 404) return null; const data = await readJson(res, "update draft"); - return toRecord(data); + const record = toRecord(data); + writeYLog({ + optType: OptType.edit, + optDetail: "B1-CM助手-会商-更新草稿", + url: `${BASE()}/${id}`, + reqParam: { id, ...patch }, + returnParam: { id: record.id, title: record.title }, + }); + return record; } export async function deleteDraft(id: string): Promise { @@ -352,6 +376,12 @@ export async function deleteDraft(id: string): Promise { const text = await res.text().catch(() => ""); throw new Error(`delete draft failed (${res.status}): ${text}`); } + writeYLog({ + optType: OptType.delete, + optDetail: "B1-CM助手-会商-删除草稿", + url: `${BASE()}/${id}`, + reqParam: { id }, + }); } /** diff --git a/frontend-web/src/roundtable-planning/api/roundtable-jobs.ts b/frontend-web/src/roundtable-planning/api/roundtable-jobs.ts index bad57302..d51aff6c 100644 --- a/frontend-web/src/roundtable-planning/api/roundtable-jobs.ts +++ b/frontend-web/src/roundtable-planning/api/roundtable-jobs.ts @@ -13,6 +13,7 @@ import { apiFetch } from "@/core/api/fetch-client"; import { getBackendBaseURL } from "@/core/config"; +import { OptType, writeYLog } from "@/core/log/y-log"; import { getAvatarTypeFor } from "../lib/roundtable-constants"; import { seatModeToRunParams } from "../lib/seat-mode"; @@ -111,7 +112,15 @@ export async function startJob(payload: StartJobPayload): Promise headers: { "Content-Type": "application/json" }, body: JSON.stringify(toStartBody(payload)), }); - return mapJob(await readJson>(res, "start job")); + const job = mapJob(await readJson>(res, "start job")); + writeYLog({ + optType: OptType.other, + optDetail: "B1-CM助手-会商-运行", + url: BASE(), + reqParam: { draftId: payload.draftId ?? null, taskId: payload.taskId ?? null, model: payload.model }, + returnParam: { id: job.id, status: job.status }, + }); + return job; } /** @@ -168,7 +177,15 @@ export async function resumeJob(jobId: string, answer: string, taskId?: string | /** 取消作业。`taskId` 非空 → task 共享取消(不分权)。 */ export async function cancelJob(jobId: string, taskId?: string | null): Promise { const res = await apiFetch(`${BASE()}/${encodeURIComponent(jobId)}/cancel${taskQuery(taskId, "?")}`, { method: "POST" }); - return mapJob(await readJson>(res, "cancel job")); + const job = mapJob(await readJson>(res, "cancel job")); + writeYLog({ + optType: OptType.edit, + optDetail: "B1-CM助手-会商-取消", + url: `${BASE()}/${jobId}/cancel`, + reqParam: { jobId, taskId: taskId ?? null }, + returnParam: { id: job.id, status: job.status }, + }); + return job; } // ── SSE 进度订阅 ───────────────────────────────────────────────────────────── diff --git a/frontend-web/src/roundtable-planning/api/task-drafts.ts b/frontend-web/src/roundtable-planning/api/task-drafts.ts index a83e3f79..7eca3882 100644 --- a/frontend-web/src/roundtable-planning/api/task-drafts.ts +++ b/frontend-web/src/roundtable-planning/api/task-drafts.ts @@ -12,6 +12,7 @@ import { apiFetch } from "@/core/api/fetch-client"; import { getBackendBaseURL } from "@/core/config"; +import { OptType, writeYLog } from "@/core/log/y-log"; import { fromCreate, @@ -33,7 +34,15 @@ const BASE = () => `${getBackendBaseURL()}/api/roundtable-task-drafts`; export async function listTaskDraftsByTask(taskId: string): Promise { const res = await apiFetch(`${BASE()}/by-task/${encodeURIComponent(taskId)}`, { method: "GET" }); const data = await readJson<{ drafts: ServerDraftMeta[] }>(res, "list task drafts"); - return (data.drafts ?? []).map(toMeta); + const metas = (data.drafts ?? []).map(toMeta); + writeYLog({ + optType: OptType.search, + optDetail: "B1-CM助手-会商任务-查询草稿", + url: `${BASE()}/by-task/${taskId}`, + reqParam: { taskId }, + returnParam: { count: metas.length }, + }); + return metas; } /** 按外部任务 id 取最新一条聊天记录(不分权)。无则 null(404)。 */ @@ -60,7 +69,15 @@ export async function createTaskDraft(payload: DraftCreatePayload): Promise(res, "create task draft"); - return toRecord(data); + const record = toRecord(data); + writeYLog({ + optType: OptType.create, + optDetail: "B1-CM助手-会商任务-新建草稿", + url: BASE(), + reqParam: { taskId: payload.taskId ?? null, title: payload.title, furthestStep: payload.furthestStep }, + returnParam: { id: record.id, title: record.title }, + }); + return record; } export async function updateTaskDraft(id: string, patch: DraftUpdatePayload): Promise { @@ -71,7 +88,15 @@ export async function updateTaskDraft(id: string, patch: DraftUpdatePayload): Pr }); if (res.status === 404) return null; const data = await readJson(res, "update task draft"); - return toRecord(data); + const record = toRecord(data); + writeYLog({ + optType: OptType.edit, + optDetail: "B1-CM助手-会商任务-更新草稿", + url: `${BASE()}/${id}`, + reqParam: { id, ...patch }, + returnParam: { id: record.id, title: record.title }, + }); + return record; } export async function deleteTaskDraft(id: string): Promise { @@ -80,4 +105,10 @@ export async function deleteTaskDraft(id: string): Promise { const text = await res.text().catch(() => ""); throw new Error(`delete task draft failed (${res.status}): ${text}`); } + writeYLog({ + optType: OptType.delete, + optDetail: "B1-CM助手-会商任务-删除草稿", + url: `${BASE()}/${id}`, + reqParam: { id }, + }); } diff --git a/frontend-web/src/strategy-components/api/light-apps.ts b/frontend-web/src/strategy-components/api/light-apps.ts index 7e16c5a5..b2a6632a 100644 --- a/frontend-web/src/strategy-components/api/light-apps.ts +++ b/frontend-web/src/strategy-components/api/light-apps.ts @@ -16,6 +16,7 @@ import { import { apiFetch } from "@/core/api/fetch-client"; import { getBackendBaseURL } from "@/core/config"; +import { OptType, writeYLog } from "@/core/log/y-log"; import { parseThemeMap, stringifyThemeMap } from "@/strategy-components/lib/light-app"; export type LightAppType = "window_open" | "iframe"; @@ -156,7 +157,15 @@ export async function listLightApps(params: ListLightAppsParams = {}): Promise(res, "list light apps"); - return (data.apps ?? []).map(toApp); + const apps = (data.apps ?? []).map(toApp); + writeYLog({ + optType: OptType.search, + optDetail: "B1-CM助手-轻应用-列表", + url: `${BASE()}${query ? `?${query}` : ""}`, + reqParam: params, + returnParam: apps, + }); + return apps; } export async function getLightApp(appId: string): Promise { @@ -170,7 +179,15 @@ export async function createLightApp(input: LightAppInput): Promise { headers: { "Content-Type": "application/json" }, body: JSON.stringify(toBody(input)), }); - return toApp(await readJson(res, "create light app")); + const app = toApp(await readJson(res, "create light app")); + writeYLog({ + optType: OptType.create, + optDetail: "B1-CM助手-轻应用-注册", + url: BASE(), + reqParam: input, + returnParam: app, + }); + return app; } export async function updateLightApp( @@ -182,12 +199,26 @@ export async function updateLightApp( headers: { "Content-Type": "application/json" }, body: JSON.stringify(toBody(patch)), }); - return toApp(await readJson(res, "update light app")); + const app = toApp(await readJson(res, "update light app")); + writeYLog({ + optType: OptType.edit, + optDetail: "B1-CM助手-轻应用-修改", + url: `${BASE()}/${encodeURIComponent(appId)}`, + reqParam: { appId, patch }, + returnParam: app, + }); + return app; } export async function deleteLightApp(appId: string): Promise { const res = await apiFetch(`${BASE()}/${encodeURIComponent(appId)}`, { method: "DELETE" }); await readJson<{ success: boolean }>(res, "delete light app"); + writeYLog({ + optType: OptType.delete, + optDetail: "B1-CM助手-轻应用-删除", + url: `${BASE()}/${encodeURIComponent(appId)}`, + reqParam: { appId }, + }); } // ── React Query hooks(管理页 / 列表页 / 侧边栏共用缓存) ─────────────────── diff --git a/frontend-web/src/strategy-components/api/menu-overrides.ts b/frontend-web/src/strategy-components/api/menu-overrides.ts index c440e20b..c904aec1 100644 --- a/frontend-web/src/strategy-components/api/menu-overrides.ts +++ b/frontend-web/src/strategy-components/api/menu-overrides.ts @@ -10,6 +10,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { apiFetch } from "@/core/api/fetch-client"; import { getBackendBaseURL } from "@/core/config"; +import { OptType, writeYLog } from "@/core/log/y-log"; import type { MenuOverride } from "@/core/page-layout/menu-overrides"; const BASE = () => `${getBackendBaseURL()}/api/menu-overrides`; @@ -67,7 +68,15 @@ export async function replaceMenuOverrides(overrides: MenuOverride[]): Promise(res, "save menu overrides"); - return (data.overrides ?? []).map(toOverride); + const saved = (data.overrides ?? []).map(toOverride); + writeYLog({ + optType: OptType.edit, + optDetail: "B1-CM助手-菜单管理-保存", + url: "/api/menu-overrides", + reqParam: { overrides }, + returnParam: saved, + }); + return saved; } // ── React Query hooks(侧边栏 + 管理页共用缓存) ──────────────────────────── diff --git a/frontend-web/src/strategy-components/api/sensitive-words.ts b/frontend-web/src/strategy-components/api/sensitive-words.ts index 7f08c9d5..0145de4a 100644 --- a/frontend-web/src/strategy-components/api/sensitive-words.ts +++ b/frontend-web/src/strategy-components/api/sensitive-words.ts @@ -10,6 +10,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { apiFetch } from "@/core/api/fetch-client"; import { getBackendBaseURL } from "@/core/config"; +import { OptType, writeYLog } from "@/core/log/y-log"; const BASE = () => `${getBackendBaseURL()}/api/sensitive-words`; @@ -71,7 +72,14 @@ async function readJson(res: Response, ctx: string): Promise { export async function listSensitiveWords(): Promise { const res = await apiFetch(BASE(), { method: "GET" }); const data = await readJson<{ words: ServerWord[] }>(res, "list sensitive words"); - return (data.words ?? []).map(toWord); + const words = (data.words ?? []).map(toWord); + writeYLog({ + optType: OptType.search, + optDetail: "B1-CM助手-敏感词管理-列表", + url: "/api/sensitive-words", + returnParam: words, + }); + return words; } export async function replaceSensitiveWords(words: SensitiveWord[]): Promise { @@ -81,7 +89,15 @@ export async function replaceSensitiveWords(words: SensitiveWord[]): Promise(res, "save sensitive words"); - return (data.words ?? []).map(toWord); + const saved = (data.words ?? []).map(toWord); + writeYLog({ + optType: OptType.edit, + optDetail: "B1-CM助手-敏感词管理-保存", + url: "/api/sensitive-words", + reqParam: { words }, + returnParam: saved, + }); + return saved; } /** 把「启用」的词条折叠成运行时脱敏所需的 `原始词 → 替换词` 映射。 */ diff --git a/frontend-web/src/strategy-components/api/skill-addresses.ts b/frontend-web/src/strategy-components/api/skill-addresses.ts index fafc387d..1c8b2143 100644 --- a/frontend-web/src/strategy-components/api/skill-addresses.ts +++ b/frontend-web/src/strategy-components/api/skill-addresses.ts @@ -11,6 +11,7 @@ import { useQuery } from "@tanstack/react-query"; import { apiFetch } from "@/core/api/fetch-client"; import { getBackendBaseURL } from "@/core/config"; +import { OptType, writeYLog } from "@/core/log/y-log"; const BASE = () => `${getBackendBaseURL()}/api/skill-addresses`; @@ -197,7 +198,15 @@ export async function scanAddresses(kinds?: AddressKind[], skill?: string): Prom if (skill) params.set("skill", skill); const qs = params.toString(); const res = await apiFetch(`${BASE()}/scan${qs ? `?${qs}` : ""}`, { method: "GET" }); - return toScanResult(await readJson(res, "扫描")); + const result = toScanResult(await readJson(res, "扫描")); + writeYLog({ + optType: OptType.search, + optDetail: "B1-CM助手-技能地址管理-扫描", + url: `${BASE()}/scan${qs ? `?${qs}` : ""}`, + reqParam: { kinds, skill }, + returnParam: result, + }); + return result; } /** 流式扫描:逐技能上报进度,返回最终聚合结果。 */ @@ -216,6 +225,13 @@ export async function scanAddressesStream( else if (ev.type === "result") result = toScanResult(ev); }); if (!result) throw new Error("扫描未返回结果"); + writeYLog({ + optType: OptType.search, + optDetail: "B1-CM助手-技能地址管理-扫描", + url: `${BASE()}/scan/stream${qs ? `?${qs}` : ""}`, + reqParam: { kinds: opts.kinds, skill: opts.skill }, + returnParam: result, + }); return result; } @@ -251,6 +267,13 @@ export async function applyReplacementsStream( else if (ev.type === "result") result = toApplyResult(ev); }); if (!result) throw new Error("应用未返回结果"); + writeYLog({ + optType: OptType.edit, + optDetail: "B1-CM助手-技能地址管理-应用替换", + url: `${BASE()}/apply/stream`, + reqParam: body, + returnParam: result, + }); return result; } @@ -269,7 +292,15 @@ export async function listBackups(): Promise { export async function rollbackBackup(backupId: string): Promise<{ restored: number }> { const res = await apiFetch(`${BASE()}/rollback/${encodeURIComponent(backupId)}`, { method: "POST" }); const data = await readJson<{ restored_files: string[] }>(res, "撤销"); - return { restored: (data.restored_files ?? []).length }; + const result = { restored: (data.restored_files ?? []).length }; + writeYLog({ + optType: OptType.edit, + optDetail: "B1-CM助手-技能地址管理-回退", + url: `${BASE()}/rollback/${encodeURIComponent(backupId)}`, + reqParam: { backupId }, + returnParam: result, + }); + return result; } // ── React Query ──────────────────────────────────────────────────────────── diff --git a/frontend-web/src/strategy-components/api/task-buttons.ts b/frontend-web/src/strategy-components/api/task-buttons.ts index 4e651388..d8c3a15a 100644 --- a/frontend-web/src/strategy-components/api/task-buttons.ts +++ b/frontend-web/src/strategy-components/api/task-buttons.ts @@ -10,6 +10,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { apiFetch } from "@/core/api/fetch-client"; import { getBackendBaseURL } from "@/core/config"; +import { OptType, writeYLog } from "@/core/log/y-log"; import type { TaskBusiness, TaskButton, @@ -87,7 +88,15 @@ export async function replaceTaskButtons(buttons: TaskButton[]): Promise(res, "save task buttons"); - return (data.buttons ?? []).map(toButton); + const saved = (data.buttons ?? []).map(toButton); + writeYLog({ + optType: OptType.edit, + optDetail: "B1-CM助手-按钮管理-保存", + url: "/api/task-buttons", + reqParam: { buttons }, + returnParam: saved, + }); + return saved; } // ── React Query hooks(工作区 + 管理页共用缓存) ──────────────────────────── diff --git a/offline-backend-20260512/backend/app/gateway/routers/public_config.py b/offline-backend-20260512/backend/app/gateway/routers/public_config.py index 861a2d8a..2b56a604 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/public_config.py +++ b/offline-backend-20260512/backend/app/gateway/routers/public_config.py @@ -26,3 +26,10 @@ async def get_task_deeplink_config() -> dict: """返回 ``config.yaml`` 的 ``task_deeplink`` 段(含 test/delete_test/各 URL/mock)。""" config = get_app_config() return config.task_deeplink.model_dump() + + +@router.get("/y-log") +async def get_y_log_config() -> dict: + """返回 ``config.yaml`` 的 ``y_log`` 段(enabled / write_url),无密钥,公开只读。""" + config = get_app_config() + return config.y_log.model_dump() diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/config/app_config.py b/offline-backend-20260512/backend/packages/harness/deerflow/config/app_config.py index b86b549e..58d09b4b 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/config/app_config.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/config/app_config.py @@ -39,6 +39,7 @@ from deerflow.config.token_usage_config import TokenUsageConfig from deerflow.config.tool_config import ToolConfig, ToolGroupConfig from deerflow.config.tool_search_config import ToolSearchConfig, load_tool_search_config_from_dict from deerflow.config.vision_config import VisionConfig +from deerflow.config.y_log_config import YLogConfig load_dotenv() @@ -141,6 +142,7 @@ class AppConfig(BaseModel): knowledge: KnowledgeConfig = Field(default_factory=KnowledgeConfig, description="Product knowledge base (obsidian-wiki compatible vault) configuration") knowledge_ingest: KnowledgeIngestConfig = Field(default_factory=KnowledgeIngestConfig, description="External knowledge-ingest endpoint configuration") task_deeplink: TaskDeeplinkConfig = Field(default_factory=TaskDeeplinkConfig, description="任务深链(rwfx)目的树闸门与跳转地址配置(前端经 /api/public/config/task-deeplink 读取)") + y_log: YLogConfig = Field(default_factory=YLogConfig, description="操作日志上报配置(前端经 /api/public/config/y-log 读取)") @classmethod def resolve_config_path(cls, config_path: str | None = None) -> Path: diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/config/y_log_config.py b/offline-backend-20260512/backend/packages/harness/deerflow/config/y_log_config.py new file mode 100644 index 00000000..33dcfeb9 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/config/y_log_config.py @@ -0,0 +1,20 @@ +"""操作日志(Y-Log)上报配置。 + +前端经公开只读接口 ``GET /api/public/config/y-log`` 读取本段(地址 + 开关), +据此决定是否把用户的关键操作上报到外部日志接口。段内只有地址与开关,无密钥, +公开只读。仿 :mod:`deerflow.config.log_system_config` 的写法。 +""" + +from __future__ import annotations + +from pydantic import BaseModel, Field + + +class YLogConfig(BaseModel): + """操作日志(Y-Log)上报配置:前端经 /api/public/config/y-log 读取。""" + + enabled: bool = Field(default=False, description="是否启用操作日志上报") + write_url: str = Field( + default="", + description="日志上报外部接口完整地址(POST),如 https://ch1.b.uat.4.cn/api/y-log/write", + ) diff --git a/offline-backend-20260512/config.yaml b/offline-backend-20260512/config.yaml index 06e067bb..3cb025ee 100644 --- a/offline-backend-20260512/config.yaml +++ b/offline-backend-20260512/config.yaml @@ -308,6 +308,14 @@ log_system: url: "https://your-log-system.example.com" label: "日志系统" +# ─────────────────────────────────────────────────────────────────────────── +# 操作日志(Y-Log)上报:前端直连外部接口记录用户的关键操作(审计/统计)。 +# 仅在「token 登录」(存在 token1)时上报;前端经公开只读接口 +# GET /api/public/config/y-log 读取本段,改这里即时生效(Gateway mtime 热重载)。 +y_log: + enabled: true + write_url: https://ch1.b.uat.4.cn/api/y-log/write + summarization: # 开关已移到聊天框下方(参考文献下面)的「上下文压缩」按钮,默认关闭。 # 这里 enabled 仅作为非 Web 路径(渠道/定时任务)的后备开关;下面的 trigger/keep From 23d170cde1e2ca893a2fae4707e726b46bc3ac53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Tue, 23 Jun 2026 16:47:42 +0800 Subject: [PATCH 6/8] =?UTF-8?q?fix(rwfx):=203q=20=E6=97=A0=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E6=97=B6=E4=B8=8D=E9=BB=98=E8=AE=A4=E5=8F=91=E9=80=81?= =?UTF-8?q?=E9=97=AE=E9=A2=98=EF=BC=8C=E4=BB=85=E3=80=8C=E4=B8=8D=E5=8E=BB?= =?UTF-8?q?3q=E5=88=86=E6=9E=90=E3=80=8D=E6=89=8D=E5=8F=91=E9=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:rwfx 进入时的 3q 预检弹框与「目的树为空自动发送第一次问题」相互独立, 导致 3q 无数据时弹框出现的同时问题已被自动发出。 修复:新增 rwfx 专用 3q 门控(rwfx-sq-gate.tsx,RwfxSqGateProvider + useRwfxSqGate), 把自动发送交给门控—— - 3q 完成/无法判定 → 立即发送; - 3q 未完成 → 弹「前往 3qfx」框并挂起发送,仅当用户「暂不(不去3q分析)」或关闭弹框才发送; 点「前往 3qfx」则跳转并丢弃发送。 TaskPurposePanel 自动开始改调 gateStart(onStart);手动「开始问答」/覆盖确认不受影响。 替换原独立的 RwfxSqReportGate(它弹同一个框但没门控自动发送)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 2 +- .../task-components/TaskFxWorkspace.tsx | 89 +-------- .../task-components/TaskPurposePanel.tsx | 6 +- .../task-components/rwfx-sq-gate.tsx | 188 ++++++++++++++++++ 4 files changed, 202 insertions(+), 83 deletions(-) create mode 100644 frontend-web/src/strategy-components/components/task-components/rwfx-sq-gate.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 63365caf..7d727829 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,7 +88,7 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA: - **LLM streaming**: `@langchain/langgraph-sdk` streams messages from the backend via SSE - **UI**: Radix UI primitives + TailwindCSS v4 + shadcn-style components in `src/components/ui/` - **Shims**: `src/shims/` provides `next/link` and `next/navigation` polyfills (original code was Next.js) -- **Login URL params** (`LoginPage.tsx`, frontend-only — the backend never sees these): besides the auth params (`ticket`/`userId`/`env`/`theme`/`yUserId`/`token`/`access_token`/`authToken`/`password`), the page supports a **task deep-link** via `?taskId=&goPath=`. After login it calls the consumer API `GET {VITE_AIPI_CONSUMER_URL}/taskAnalyseSearch/cop-task-detail?taskId=` (header `Authorization: Bearer admin`, auto-injected by `utils/request.ts`), stashes the full `data` (`id/overview/taskDirection/taskName/taskContent`) into `sessionStorage["login.copTaskDetail"]`, then routes by `goPath`: base codes `3qfx`/`rwfx`/`xdfx` → that business's configured **single agent** Q&A inside a **任务工作区** (resolved via `/api/business-mapping`; `resolveTaskDeeplinkTarget` calls `persistTaskContext({taskId, agentId, openingText})` and navigates to the `…/chats` **index**, which opens the latest task conversation or 新建 — a 1:N task↔对话 workspace, NOT `pendingAgentMessage`); the `-A` suffix (`3qfx-A`/…) → **multi-agent roundtable** with `roundtablePrefill.businessCode` so the page auto-selects the configured 业务链条. **xdfx's deep-link `?taskId=` is actually an 行动id, not a 任务id** — `resolveDeeplinkTaskId(goPath, rawId)` (called in `LoginPage` before everything) detects `xdfx`/`xdfx-A` and resolves it via `GET {action_detail_url}/{actionId}` → `data.taskId` (`fetchTaskIdFromAction`; `action_test`+`mock.action_detail` for offline testing); the resolved 任务id then drives `cop-task-detail`, the purpose tree, and the 1:N binding (other businesses pass the id through unchanged). **xdfx additionally partitions its chat records by 行动id (action_id), NOT by task_id**: `resolveTaskDeeplinkTarget` sets `partitionByAction:true` + keeps the raw action_id as `TaskContext.actionId`, so new threads are tagged `metadata.{taskId,actionId}` (taskId kept only to preserve the backend「任务深链对话全局共享」semantics, which keys off `metadata.taskId`) and the conversation list filters by `actionId` (+`agent_id`) — different actions of the same task get separate lists. xdfx's first-question opening text also appends `行动id(action_id)为…` (see `buildOpeningText` / `taskThreadMetadata`). goPath→businessCode: `3qfx`→`3Q`, `rwfx`→`6BF`, `xdfx`→`7BF`. The roundtable target also threads the URL `taskId` into `roundtablePrefill.taskId` (`resolveTaskDeeplinkTarget(goPath, detail, taskId)`), which turns on two taskId-scoped behaviors: (1) **separate, unpartitioned per-task storage** — taskId 会商聊天记录 live in their **own** store/table (`roundtable_task_drafts`), keyed **only by `task_id`** (one taskId = many records, shared across users, **no user 分权** at all; `created_by` is audit-only), kept entirely out of the per-user personal store (`roundtable_drafts`, now strictly per-user) so the two never mix. Backend router `/api/roundtable-task-drafts` (`GET /by-task/{id}` + `/by-task/{id}/latest`, `POST`, `GET/PUT/DELETE /{id}` — all user-agnostic); frontend client `roundtable-planning/api/task-drafts.ts`, and `useDraftPersistence`/`useRoundtableDrafts` route **all** CRUD to it when a `taskId` is present (else the personal `drafts.ts`). The matching **background jobs** are shared by task too: `roundtable_jobs.task_id` marks a task job; reads/dedup/stream/resume/cancel pass `?task_id=` so any user opening the task sees the same running 研讨 (progress/flowchart), and the executor writes the final report back to the task-draft store. Existing rows are moved over by `scripts/migrate_roundtable_task_drafts.py` (migration `20260618_01`). The history dropdown filters to the task and 新建 keeps the binding; (2) **Step-3 auto structured output** — after the `roundtable-summary` agent writes the Markdown report it automatically runs a second round emitting a `flow-json` flowchart (`useStep3Summary` `autoFlow`+`chainLevels`), whose hierarchy follows the business chain's stage goals (任务 → 目的 → 行为体 → …; coordinatorPrompt-specified hierarchy wins) and whose nodes never carry 阶段编号/阶段名 (the prior bug). **Both targets render embedded under the 任务管理 layout** — nested routes under `task-management//{roundtable,agent/:agent_id/chats}` in `StrategyRoutes.tsx` (`` = `plan-task-sentiment` / `plan-task/plan-task-fx` / `plan-task/plan-task-action`, matching the COH中任务 menu paths in `sidebar-menu.ts`) so the global sidebar keeps 任务管理 → COH中任务 → the matching business highlighted. **All three single-agent businesses share `TaskFxWorkspace`** (`task-components/TaskFxWorkspace.tsx`): left = a「当前任务」card (`readCopTaskDetail()` 任务名称 + 简介, desensitized, 留存 above 新建对话) + per-task 1:N conversation list (`useTaskThreads(taskId, agentId, actionId?)` — filtered by **both** `metadata.taskId` AND `metadata.agent_id` so the three businesses' conversations stay isolated even though they share one taskId; **xdfx** passes a third `actionId` so it filters by `metadata.actionId` too, partitioning chat records per 行动 — `useChatsBase` derives it from `TaskContext.partitionByAction`) + 新建对话; the `…/chats` index (`TaskFxIndexResolver`) opens the latest task thread or 新建. The right `:thread_id` host differs by business: **rwfx** → `TaskFxChat` (`AgentChatPage taskGate`) = full workspace (non-`lite`): 目的树闸门 panel + 清除记录 (placed under 新建对话); **xdfx** → also `TaskFxChat` (same 目的树闸门 query+display, same `purpose_detail_url`) but workspace passed `lite`; **3qfx** → `TaskBindChat` (`AgentChatPage taskBind`, `lite`) = 1:N list only, no gate, and 新建对话 **auto-sends** the task opening text (`taskAutoSendText`; gate businesses send it via the gate's 开始问答 instead). **rwfx auto-starts the first Q&A when the 任务目的 query returns empty** (`TaskPurposePanel autoStartWhenEmpty`, threaded from the rwfx `TaskFxChat` only). The 目的树闸门 (`TaskPurposePanel`) defaults **collapsed** and now keeps only 弹框查看 + 开始问答; the former jump buttons (编辑/下一步/3q详情/切换三情/弹框查看) all moved to the **configurable bottom 快捷跳转 bar** (`TaskJumpBar`, red 红底白字 buttons, right-aligned) driven by 按钮管理 — see the 按钮管理 bullet. `TaskPurposeDialog` (exported from `TaskPurposePanel`) backs both the gate zoom and the bottom bar's `purpose`-type button. `chatBasePath` keeps the post-send URL rewrite + new-chat button in the task-management context. Backend config `task_deeplink` (`config.yaml`, surfaced by `GET /api/public/config/task-deeplink`, mirrored in `core/auth/task-deeplink-config.ts`) adds `action_test`/`action_detail_url`/`detail_3q_url` + `mock.action_detail`. Any failure (no consumer URL / fetch error / unmapped goPath / unresolvable action id / no configured agent) silently falls back to the default home. Helpers: `core/auth/task-deeplink.ts`, `core/auth/task-purpose.ts`, `core/auth/task-deeplink-config.ts`, `core/threads/use-task-threads.ts`. +- **Login URL params** (`LoginPage.tsx`, frontend-only — the backend never sees these): besides the auth params (`ticket`/`userId`/`env`/`theme`/`yUserId`/`token`/`access_token`/`authToken`/`password`), the page supports a **task deep-link** via `?taskId=&goPath=`. After login it calls the consumer API `GET {VITE_AIPI_CONSUMER_URL}/taskAnalyseSearch/cop-task-detail?taskId=` (header `Authorization: Bearer admin`, auto-injected by `utils/request.ts`), stashes the full `data` (`id/overview/taskDirection/taskName/taskContent`) into `sessionStorage["login.copTaskDetail"]`, then routes by `goPath`: base codes `3qfx`/`rwfx`/`xdfx` → that business's configured **single agent** Q&A inside a **任务工作区** (resolved via `/api/business-mapping`; `resolveTaskDeeplinkTarget` calls `persistTaskContext({taskId, agentId, openingText})` and navigates to the `…/chats` **index**, which opens the latest task conversation or 新建 — a 1:N task↔对话 workspace, NOT `pendingAgentMessage`); the `-A` suffix (`3qfx-A`/…) → **multi-agent roundtable** with `roundtablePrefill.businessCode` so the page auto-selects the configured 业务链条. **xdfx's deep-link `?taskId=` is actually an 行动id, not a 任务id** — `resolveDeeplinkTaskId(goPath, rawId)` (called in `LoginPage` before everything) detects `xdfx`/`xdfx-A` and resolves it via `GET {action_detail_url}/{actionId}` → `data.taskId` (`fetchTaskIdFromAction`; `action_test`+`mock.action_detail` for offline testing); the resolved 任务id then drives `cop-task-detail`, the purpose tree, and the 1:N binding (other businesses pass the id through unchanged). **xdfx additionally partitions its chat records by 行动id (action_id), NOT by task_id**: `resolveTaskDeeplinkTarget` sets `partitionByAction:true` + keeps the raw action_id as `TaskContext.actionId`, so new threads are tagged `metadata.{taskId,actionId}` (taskId kept only to preserve the backend「任务深链对话全局共享」semantics, which keys off `metadata.taskId`) and the conversation list filters by `actionId` (+`agent_id`) — different actions of the same task get separate lists. xdfx's first-question opening text also appends `行动id(action_id)为…` (see `buildOpeningText` / `taskThreadMetadata`). goPath→businessCode: `3qfx`→`3Q`, `rwfx`→`6BF`, `xdfx`→`7BF`. The roundtable target also threads the URL `taskId` into `roundtablePrefill.taskId` (`resolveTaskDeeplinkTarget(goPath, detail, taskId)`), which turns on two taskId-scoped behaviors: (1) **separate, unpartitioned per-task storage** — taskId 会商聊天记录 live in their **own** store/table (`roundtable_task_drafts`), keyed **only by `task_id`** (one taskId = many records, shared across users, **no user 分权** at all; `created_by` is audit-only), kept entirely out of the per-user personal store (`roundtable_drafts`, now strictly per-user) so the two never mix. Backend router `/api/roundtable-task-drafts` (`GET /by-task/{id}` + `/by-task/{id}/latest`, `POST`, `GET/PUT/DELETE /{id}` — all user-agnostic); frontend client `roundtable-planning/api/task-drafts.ts`, and `useDraftPersistence`/`useRoundtableDrafts` route **all** CRUD to it when a `taskId` is present (else the personal `drafts.ts`). The matching **background jobs** are shared by task too: `roundtable_jobs.task_id` marks a task job; reads/dedup/stream/resume/cancel pass `?task_id=` so any user opening the task sees the same running 研讨 (progress/flowchart), and the executor writes the final report back to the task-draft store. Existing rows are moved over by `scripts/migrate_roundtable_task_drafts.py` (migration `20260618_01`). The history dropdown filters to the task and 新建 keeps the binding; (2) **Step-3 auto structured output** — after the `roundtable-summary` agent writes the Markdown report it automatically runs a second round emitting a `flow-json` flowchart (`useStep3Summary` `autoFlow`+`chainLevels`), whose hierarchy follows the business chain's stage goals (任务 → 目的 → 行为体 → …; coordinatorPrompt-specified hierarchy wins) and whose nodes never carry 阶段编号/阶段名 (the prior bug). **Both targets render embedded under the 任务管理 layout** — nested routes under `task-management//{roundtable,agent/:agent_id/chats}` in `StrategyRoutes.tsx` (`` = `plan-task-sentiment` / `plan-task/plan-task-fx` / `plan-task/plan-task-action`, matching the COH中任务 menu paths in `sidebar-menu.ts`) so the global sidebar keeps 任务管理 → COH中任务 → the matching business highlighted. **All three single-agent businesses share `TaskFxWorkspace`** (`task-components/TaskFxWorkspace.tsx`): left = a「当前任务」card (`readCopTaskDetail()` 任务名称 + 简介, desensitized, 留存 above 新建对话) + per-task 1:N conversation list (`useTaskThreads(taskId, agentId, actionId?)` — filtered by **both** `metadata.taskId` AND `metadata.agent_id` so the three businesses' conversations stay isolated even though they share one taskId; **xdfx** passes a third `actionId` so it filters by `metadata.actionId` too, partitioning chat records per 行动 — `useChatsBase` derives it from `TaskContext.partitionByAction`) + 新建对话; the `…/chats` index (`TaskFxIndexResolver`) opens the latest task thread or 新建. The right `:thread_id` host differs by business: **rwfx** → `TaskFxChat` (`AgentChatPage taskGate`) = full workspace (non-`lite`): 目的树闸门 panel + 清除记录 (placed under 新建对话); **xdfx** → also `TaskFxChat` (same 目的树闸门 query+display, same `purpose_detail_url`) but workspace passed `lite`; **3qfx** → `TaskBindChat` (`AgentChatPage taskBind`, `lite`) = 1:N list only, no gate, and 新建对话 **auto-sends** the task opening text (`taskAutoSendText`; gate businesses send it via the gate's 开始问答 instead). **rwfx auto-starts the first Q&A when the 任务目的 query returns empty** (`TaskPurposePanel autoStartWhenEmpty`, threaded from the rwfx `TaskFxChat` only), **but gated by the 3q precheck**: on entering the rwfx workspace `RwfxSqGateProvider` (`task-components/rwfx-sq-gate.tsx`, wraps the workspace, rwfx only) calls `fetchSqReportStatus(taskId)` — `incomplete`(3q 无数据) pops the「3q 未完成,是否前往 3qfx」dialog AND **holds the auto-send**; the question is only sent when the user picks「暂不」(or dismisses — i.e. does NOT go to 3q analysis), while「前往 3qfx」navigates and discards the send. `complete`/`unknown` send immediately (no dialog). The panel's auto-start calls `useRwfxSqGate().gateStart(onStart)` instead of `onStart()`; the manual「开始问答」/覆盖确认 path is unchanged (ungated). This replaced the old standalone `RwfxSqReportGate` (which popped the same dialog but did not gate the auto-send). The 目的树闸门 (`TaskPurposePanel`) defaults **collapsed** and now keeps only 弹框查看 + 开始问答; the former jump buttons (编辑/下一步/3q详情/切换三情/弹框查看) all moved to the **configurable bottom 快捷跳转 bar** (`TaskJumpBar`, red 红底白字 buttons, right-aligned) driven by 按钮管理 — see the 按钮管理 bullet. `TaskPurposeDialog` (exported from `TaskPurposePanel`) backs both the gate zoom and the bottom bar's `purpose`-type button. `chatBasePath` keeps the post-send URL rewrite + new-chat button in the task-management context. Backend config `task_deeplink` (`config.yaml`, surfaced by `GET /api/public/config/task-deeplink`, mirrored in `core/auth/task-deeplink-config.ts`) adds `action_test`/`action_detail_url`/`detail_3q_url` + `mock.action_detail`. Any failure (no consumer URL / fetch error / unmapped goPath / unresolvable action id / no configured agent) silently falls back to the default home. Helpers: `core/auth/task-deeplink.ts`, `core/auth/task-purpose.ts`, `core/auth/task-deeplink-config.ts`, `core/threads/use-task-threads.ts`. - **iframe 嵌入模式 (外部系统用 iframe 套用深链问答/会商)** — 三个**按标签页**开关,全部由深链 URL 参数开启、存进同一份 `sessionStorage` 会话(`embed.session`,键名常量在 `core/embed/embed-session.ts`),跨登录跳转/刷新/页内导航存活,**绝不跨标签污染**同浏览器另开的正常系统页。核心隔离手法:用 `sessionStorage`(按标签页/浏览上下文隔离,iframe 即便同源也有独立一份)而非 `localStorage`(跨标签共享)。开关:(1) **`?embed=1`(可带 `&theme=`)** — 去全局 chrome(`PageLayout` 的 `pageSidebarAllowed && !isEmbedActive()` → 隐藏 Header + PageSidebar)+ 按外部主题色**强制**明/暗:走 next-themes 的 `forcedTheme`(`App.tsx` `AppProviders` 调 `resolveForcedEmbedTheme`,**不写** localStorage `strategy-theme`),离散映射 `dark-blue`/`dark`→深、其余→浅;嵌入侧**绝不**调 `setTheme`/`persistSidebarEnabled`(那俩落 localStorage 跨标签污染,`LoginPage` 据 `readEmbedParam` 跳过)。(2) **`?hideJump=1`** — 隐藏深链底部「快捷跳转」栏 `TaskJumpBar` 的两部分(底部「下一步」等按钮 + 右侧垂直居中悬浮菜单),单智能体问答 + 会商共用该栏,`isJumpBarHidden()` 为真时整条 `return null`。(3) **登录用户隔离** — 嵌入态下鉴权/身份键(`deerflow.auth` token、`userInfo`、`login.*`、`tools_token`、maxkey/userDetail token)全部经 `core/auth/scoped-storage.ts` 的 `authStorage()`(嵌入→sessionStorage / 正常→localStorage)读写,使 iframe 里登录的用户与正常登录用户**完全隔离**、互不覆盖;所有 token 读取都过 `getStoredAuth()`/`getAuthorizationHeaderValue()`(API 客户端 `core/api/*` 全走这俩),改中心函数即让全体调用方自动隔离;跨标签账号变更监听 `AuthSyncWatcher`(`core/auth/sync.tsx`)在嵌入态禁用。嵌入会话在 `main.tsx` 渲染前 `syncEmbedSessionFromCurrentUrl()` 同步启用(早于任何鉴权写入,避免首帧 effect 顺序漏写 localStorage);`AppProviders` 的 effect 再按 SPA 导航同步。`embed=0`/`hideJump=0` 可显式关闭。正常(非嵌入)标签页所有 `isXxx()` 恒 false,行为**零变化**。 - **操作日志(Y-Log)上报** — 用户的关键操作(登录登出/查询/查看/增删改/启停/导入导出/问答等)成功后,**前端直连**外部接口 `POST {write_url}`(默认 `https://ch1.b.uat.4.cn/api/y-log/write`)记一条审计日志。核心模块 `src/core/log/`:`y-log.ts`(`writeYLog(params)` + `OptType` 枚举 0/1/3..12;**异步即发即忘、永不 await、永不抛错、失败静默**,自动把完整 url 转相对路径去 ip/host、对象入参/返回自动 `JSON.stringify` + 50k 裁剪、`optResult` 恒 0 只报成功),`y-log-config.ts`(从公开只读 `GET /api/public/config/y-log` 拉 `{enabled, writeUrl}`,模块级缓存 + 去重 + 默认兜底;`App.tsx` `AppProviders` 启动预热 `fetchYLogConfig()`),`y-log-token.ts`(token1 = token 登录的 `?authToken=`,经 [[scoped-storage]] 的 `authStorage*` 存取,嵌入态自动隔离)。**双门控**:仅当①存在 token1(即用 token 登录,`LoginPage` 成功后 `persistYLogToken(authToken)`,登出 `account-settings-page.tsx` 先报后清 token1)且②配置 `enabled && writeUrl` 才上报;否则一律不发。`optDetail` 统一「B1-CM助手-…」(**禁内部框架名**)。埋点集中在各 API client 的成功路径:`strategy-components/api/{sensitive-words,menu-overrides,task-buttons,skill-addresses,light-apps}.ts`、`roundtable-planning/api/{drafts,task-drafts,roundtable-jobs}.ts`、`core/{agents,scheduled-tasks,knowledge}/api.ts`、`core/api/markdown-docx-export.ts`,以及问答完成钩子(`ChatPage`/`AgentChatPage` 的 `useThreadStream` `onFinish`,用最终汇总文本作 `returnParam`,optType 12)。后端仅新增公开只读配置:`deerflow/config/y_log_config.py`(`YLogConfig{enabled,write_url}`)挂到 `AppConfig.y_log`,`app/gateway/routers/public_config.py` 加 `GET /api/public/config/y-log`,地址写在 `config.yaml` 的 `y_log` 段(mtime 热重载,前端无需重建);**不做后端代理**。Tests:`tests/test_y_log_config.py`。 diff --git a/frontend-web/src/strategy-components/components/task-components/TaskFxWorkspace.tsx b/frontend-web/src/strategy-components/components/task-components/TaskFxWorkspace.tsx index ebee077c..93effc72 100644 --- a/frontend-web/src/strategy-components/components/task-components/TaskFxWorkspace.tsx +++ b/frontend-web/src/strategy-components/components/task-components/TaskFxWorkspace.tsx @@ -33,10 +33,8 @@ import { deleteTaskPurpose, readTaskContext } from "@/core/auth/task-purpose"; import { fetchActionDetail, readCopTaskDetail, - resolveTaskDeeplinkTarget, type ActionDetail, } from "@/core/auth/task-deeplink"; -import { fetchSqReportStatus } from "@/core/auth/dashboard-input"; import { TASK_BUSINESSES, type TaskBusiness } from "@/core/auth/task-buttons"; import { useTaskThreads } from "@/core/threads/use-task-threads"; import { titleOfThread } from "@/core/threads/utils"; @@ -45,6 +43,7 @@ import { cn } from "@/lib/utils"; import AgentChatPage from "@/pages/AgentChatPage"; import { ChatRuntime } from "@/pages/WorkspaceRoutes"; import { TaskJumpBar } from "@/strategy-components/components/task-components/TaskJumpBar"; +import { RwfxSqGateProvider } from "@/strategy-components/components/task-components/rwfx-sq-gate"; function useChatsBase(taskBase: string): { taskId: string; @@ -293,82 +292,6 @@ function TaskClearButton({ taskId }: { taskId: string }) { ); } -/** - * rwfx 专用「3q 完成预检」闸门:进入任务分析工作区时调研判情报报告接口 - * (``GET {VITE_AIPI_CONSUMER_URL}/cop/selectSqReport?taskId=``)。若该任务的报告为空 - * (数组为空 / 内容全为 null,即 3q 未完成)→ 弹框提示「3q 未完成,是否进行 3qfx」, - * 并提供「前往 3qfx」按钮切换到**同一任务**的三情分析工作区(复用深链解析重绑任务上下文)。 - * 报告正常 / 接口不可判定(缺地址、请求失败)时不打扰用户。见 [[dashboard-input]] / [[task-deeplink]]。 - */ -function RwfxSqReportGate({ taskId }: { taskId: string }) { - const navigate = useNavigate(); - const [open, setOpen] = useState(false); - const [jumping, setJumping] = useState(false); - - useEffect(() => { - if (!taskId) return; - let alive = true; - void fetchSqReportStatus(taskId).then((status) => { - // 仅「明确为空(3q 未完成)」才弹框;complete / unknown 静默放行。 - if (alive && status === "incomplete") setOpen(true); - }); - return () => { - alive = false; - }; - }, [taskId]); - - const handleGo3qfx = async () => { - const detail = readCopTaskDetail(); - if (!detail || !taskId) { - toast.error("缺少任务信息,无法跳转"); - return; - } - setJumping(true); - try { - // 复用深链解析:会清掉当前 rwfx 任务上下文并按 3qfx 业务重新绑定(同一 taskId)。 - const target = await resolveTaskDeeplinkTarget("3qfx", detail, taskId); - if (target) { - setOpen(false); - navigate(target.path, { state: target.state }); - } else { - toast.error("未配置三情分析(3qfx)对应的业务智能体"); - } - } catch { - toast.error("跳转失败,请稍后再试"); - } finally { - setJumping(false); - } - }; - - return ( - !jumping && setOpen(o)}> - - - 3q 未完成 - - 未查询到该任务的研判情报报告(3q 未完成),是否前往该任务的三情分析(3qfx)? - - - - - - - - - ); -} - /** * 任务工作区布局:左列表 + 右 Outlet。 * ``lite``(3qfx/xdfx)= 精简版:左列表无跳转按钮、无底部任务操作面板。 @@ -383,7 +306,7 @@ export function TaskFxWorkspace({ lite?: boolean; }) { const { taskId, agentId, actionId, chatsBase } = useChatsBase(taskBase); - return ( + const content = (
- {/* rwfx 专属:进入时预检 3q 是否完成,未完成则弹框引导去 3qfx。 */} - {business === "rwfx" && }
); + // rwfx 专属:3q 预检门控。进入时若 3q 未完成弹「前往 3qfx」框;并门控目的树为空时的 + // 自动发送——3q 无数据时不默认发送,仅当用户点「暂不(不去 3q 分析)」才发送。 + if (business === "rwfx") { + return {content}; + } + return content; } /** chats 索引:有历史对话 → 跳最新;否则 → 跳 new(rwfx 走闸门 / 3qfx/xdfx 直接带开场信息)。 */ diff --git a/frontend-web/src/strategy-components/components/task-components/TaskPurposePanel.tsx b/frontend-web/src/strategy-components/components/task-components/TaskPurposePanel.tsx index e1b052b5..61c494c5 100644 --- a/frontend-web/src/strategy-components/components/task-components/TaskPurposePanel.tsx +++ b/frontend-web/src/strategy-components/components/task-components/TaskPurposePanel.tsx @@ -31,6 +31,7 @@ import { type TaskPurposeResult, } from "@/core/auth/task-purpose"; import { fetchTaskIdFromAction } from "@/core/auth/task-deeplink"; +import { useRwfxSqGate } from "@/strategy-components/components/task-components/rwfx-sq-gate"; import { desensitize } from "@/lib/desensitize"; import { cn } from "@/lib/utils"; @@ -176,6 +177,8 @@ export function TaskPurposePanel({ const [collapsed, setCollapsed] = useState(true); // rwfx 空数据自动开始:防止重复触发。 const autoStartedRef = useRef(false); + // rwfx 3q 预检门控:3q 无数据时不默认发送,交由门控(仅「暂不」才发送)。 + const { gateStart } = useRwfxSqGate(); useEffect(() => { let cancelled = false; @@ -185,9 +188,10 @@ export function TaskPurposePanel({ if (cancelled) return; setResult(r); // 查询任务目的为空数据 → 默认直接开始第一次问答(跳过手动「开始问答」)。 + // 经 3q 门控:3q 完成/未知立即发送;3q 未完成则挂起,仅当用户「暂不(不去 3q 分析)」才发送。 if (autoStartWhenEmpty && !r.hasData && !autoStartedRef.current) { autoStartedRef.current = true; - onStart(); + gateStart(onStart); } }) .catch(() => { diff --git a/frontend-web/src/strategy-components/components/task-components/rwfx-sq-gate.tsx b/frontend-web/src/strategy-components/components/task-components/rwfx-sq-gate.tsx new file mode 100644 index 00000000..2059dead --- /dev/null +++ b/frontend-web/src/strategy-components/components/task-components/rwfx-sq-gate.tsx @@ -0,0 +1,188 @@ +/** + * rwfx(任务分析)专用「3q 预检门控」。 + * + * 进入任务分析工作区时调研判情报报告接口(``fetchSqReportStatus``)判断 3q 是否完成: + * - ``incomplete``(3q 未完成 / 无数据)→ 弹「3q 未完成,是否前往 3qfx」框(暂不 / 前往 3qfx)。 + * - ``complete`` / ``unknown`` → 不打扰。 + * + * 关键点:rwfx「目的树为空时自动发送第一次问题」必须**经本门控**(``gateStart``),不能在 3q + * 无数据时还默认发送: + * - 3q 完成 / 无法判定 → 立即发送; + * - 3q 未完成 → 挂起,**仅当用户点「暂不」(不去 3q 分析)才发送**;点「前往 3qfx」则跳转、丢弃发送。 + * + * Provider 由 ``TaskFxWorkspace``(仅 rwfx)包裹整个工作区;``TaskPurposePanel`` 经 ``useRwfxSqGate`` + * 读取门控。非 rwfx / 无 Provider → ``gateStart`` 直接发送(默认值)。见 [[dashboard-input]] / [[task-deeplink]]。 + */ + +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { useNavigate } from "react-router-dom"; +import { Loader2 } from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { fetchSqReportStatus } from "@/core/auth/dashboard-input"; +import { readCopTaskDetail, resolveTaskDeeplinkTarget } from "@/core/auth/task-deeplink"; + +type GateStatus = "loading" | "incomplete" | "ok"; + +interface RwfxSqGateValue { + /** + * rwfx 自动开始第一次问答的门控。 + * - 3q 完成 / 无法判定 → 立即 ``start()``。 + * - 3q 未完成 → 挂起 ``start``,弹「前往 3qfx」框;用户点「暂不」才 ``start()``,点「前往 3qfx」跳转并丢弃。 + * 非 rwfx / 无 Provider → 直接 ``start()``(默认值)。 + */ + gateStart: (start: () => void) => void; +} + +const RwfxSqGateContext = createContext({ + gateStart: (start) => start(), +}); + +export function useRwfxSqGate(): RwfxSqGateValue { + return useContext(RwfxSqGateContext); +} + +export function RwfxSqGateProvider({ + taskId, + children, +}: { + taskId: string; + children: ReactNode; +}) { + const navigate = useNavigate(); + const [open, setOpen] = useState(false); + const [jumping, setJumping] = useState(false); + + const statusRef = useRef(taskId ? "loading" : "ok"); + // 待触发的「开始问答」回调:3q 未完成时挂起,点「暂不」/关闭弹框后触发。 + const pendingStartRef = useRef<(() => void) | null>(null); + + // taskId 变化:重置门控状态并预检 3q。 + useEffect(() => { + pendingStartRef.current = null; + setOpen(false); + if (!taskId) { + statusRef.current = "ok"; + return; + } + statusRef.current = "loading"; + let alive = true; + void fetchSqReportStatus(taskId).then((s) => { + if (!alive) return; + // complete / unknown 都按「放行」(不打扰);仅明确 incomplete 才门控。 + const next: GateStatus = s === "incomplete" ? "incomplete" : "ok"; + statusRef.current = next; + if (next === "incomplete") { + setOpen(true); // 进入即提醒(无论是否有挂起的自动发送) + } else if (pendingStartRef.current) { + // 加载期间已请求自动开始 → 放行后补发。 + const start = pendingStartRef.current; + pendingStartRef.current = null; + start(); + } + }); + return () => { + alive = false; + }; + }, [taskId]); + + const gateStart = useCallback((start: () => void) => { + const status = statusRef.current; + if (status === "ok") { + start(); + return; + } + // loading / incomplete:挂起。loading 解析为 ok 时补发;incomplete 时等用户「暂不」。 + pendingStartRef.current = start; + if (status === "incomplete") setOpen(true); + }, []); + + /** 暂不(不去 3q 分析)/ 关闭弹框 → 补发挂起的「开始问答」(无则无操作)。 */ + const stayAndStart = useCallback(() => { + setOpen(false); + const start = pendingStartRef.current; + pendingStartRef.current = null; + start?.(); + }, []); + + const handleGo3qfx = async () => { + const detail = readCopTaskDetail(); + if (!detail || !taskId) { + toast.error("缺少任务信息,无法跳转"); + return; + } + setJumping(true); + try { + // 复用深链解析:清掉当前 rwfx 任务上下文并按 3qfx 业务重新绑定(同一 taskId)。 + const target = await resolveTaskDeeplinkTarget("3qfx", detail, taskId); + if (target) { + pendingStartRef.current = null; // 去 3q 分析 → 丢弃自动发送 + setOpen(false); + navigate(target.path, { state: target.state }); + } else { + toast.error("未配置三情分析(3qfx)对应的业务智能体"); + } + } catch { + toast.error("跳转失败,请稍后再试"); + } finally { + setJumping(false); + } + }; + + const value = useMemo(() => ({ gateStart }), [gateStart]); + + return ( + + {children} + { + if (jumping) return; + if (o) setOpen(true); + else stayAndStart(); // 关闭(暂不/ESC/点外部)= 不去 3q 分析 → 发送 + }} + > + + + 3q 未完成 + + 未查询到该任务的研判情报报告(3q 未完成),是否前往该任务的三情分析(3qfx)? + + + + + + + + + + ); +} From 5aa66fc07db9bd02330dfd9952da4c6d0e866b8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Tue, 23 Jun 2026 16:57:21 +0800 Subject: [PATCH 7/8] =?UTF-8?q?chore(backend):=20=E6=94=BE=E5=AE=BD?= =?UTF-8?q?=E9=80=92=E5=BD=92=E4=B8=8E=E5=BE=AA=E7=8E=AF=E6=A3=80=E6=B5=8B?= =?UTF-8?q?=E9=98=88=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - build_run_config recursion_limit 100 -> 250 - LoopDetectionMiddleware 默认阈值放宽(warn/hard/window/tool-freq) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../backend/app/gateway/services.py | 2 +- .../middlewares/loop_detection_middleware.py | 20 +++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/offline-backend-20260512/backend/app/gateway/services.py b/offline-backend-20260512/backend/app/gateway/services.py index d16243a3..761ee3eb 100644 --- a/offline-backend-20260512/backend/app/gateway/services.py +++ b/offline-backend-20260512/backend/app/gateway/services.py @@ -314,7 +314,7 @@ def build_run_config( the LangGraph Platform-compatible HTTP API and the IM channel path behave identically. """ - config: dict[str, Any] = {"recursion_limit": 100} + config: dict[str, Any] = {"recursion_limit": 250} if request_config: # LangGraph >= 0.6.0 introduced ``context`` as the preferred way to # pass thread-level data and rejects requests that include both diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/loop_detection_middleware.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/loop_detection_middleware.py index e7b053bd..b949163c 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/loop_detection_middleware.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/loop_detection_middleware.py @@ -28,12 +28,12 @@ from langgraph.runtime import Runtime logger = logging.getLogger(__name__) # Defaults — can be overridden via constructor -_DEFAULT_WARN_THRESHOLD = 3 # inject warning after 3 identical calls -_DEFAULT_HARD_LIMIT = 5 # force-stop after 5 identical calls -_DEFAULT_WINDOW_SIZE = 20 # track last N tool calls +_DEFAULT_WARN_THRESHOLD = 8 # inject warning after 8 identical calls +_DEFAULT_HARD_LIMIT = 20 # force-stop after 20 identical calls +_DEFAULT_WINDOW_SIZE = 60 # track last N tool calls (must be >= hard_limit) _DEFAULT_MAX_TRACKED_THREADS = 100 # LRU eviction limit -_DEFAULT_TOOL_FREQ_WARN = 30 # warn after 30 calls to the same tool type -_DEFAULT_TOOL_FREQ_HARD_LIMIT = 50 # force-stop after 50 calls to the same tool type +_DEFAULT_TOOL_FREQ_WARN = 80 # warn after 80 calls to the same tool type +_DEFAULT_TOOL_FREQ_HARD_LIMIT = 120 # force-stop after 120 calls to the same tool type def _normalize_tool_call_args(raw_args: object) -> tuple[dict, str | None]: @@ -142,19 +142,19 @@ class LoopDetectionMiddleware(AgentMiddleware[AgentState]): Args: warn_threshold: Number of identical tool call sets before injecting - a warning message. Default: 3. + a warning message. Default: 8. hard_limit: Number of identical tool call sets before stripping - tool_calls entirely. Default: 5. + tool_calls entirely. Default: 20. window_size: Size of the sliding window for tracking calls. - Default: 20. + Default: 60. max_tracked_threads: Maximum number of threads to track before evicting the least recently used. Default: 100. tool_freq_warn: Number of calls to the same tool *type* (regardless of arguments) before injecting a frequency warning. Catches cross-file read loops that hash-based detection misses. - Default: 30. + Default: 80. tool_freq_hard_limit: Number of calls to the same tool type before - forcing a stop. Default: 50. + forcing a stop. Default: 120. """ def __init__( From a1fbfaf8643e7c5e9ed5e79cef2d122a980e52da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Tue, 23 Jun 2026 19:14:16 +0800 Subject: [PATCH 8/8] =?UTF-8?q?feat(xdfx):=20=E8=A1=8C=E5=8A=A8=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E6=9F=A5=E8=AF=A2=E4=B8=8E=E5=88=A0=E9=99=A4=EF=BC=88?= =?UTF-8?q?=E4=B8=8E=20rwfx=20=E5=85=B1=E7=94=A8=20dropSixOrSevenData=20?= =?UTF-8?q?=E5=88=A0=E9=99=A4=E6=8E=A5=E5=8F=A3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除接口统一为 dropSixOrSevenData:rwfx 传 ?taskId=、xdfx 传 ?actionId=,另一参数直接不出现在 URL - xdfx 工作区新增「清除记录」按钮(按行动id 删除),删除前预检「行动历史是否存在」 - action_history_url 配置位(地址待提供,留空则不查询/不阻断删除) Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 2 +- .../src/core/auth/task-deeplink-config.ts | 10 +- frontend-web/src/core/auth/task-purpose.ts | 55 ++++++++- .../task-components/TaskFxWorkspace.tsx | 106 +++++++++++++++++- offline-backend-20260512/backend/config.yaml | 5 +- .../deerflow/config/task_deeplink_config.py | 11 +- offline-backend-20260512/config.yaml | 5 +- 7 files changed, 182 insertions(+), 12 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7d727829..deaf52df 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,7 +88,7 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA: - **LLM streaming**: `@langchain/langgraph-sdk` streams messages from the backend via SSE - **UI**: Radix UI primitives + TailwindCSS v4 + shadcn-style components in `src/components/ui/` - **Shims**: `src/shims/` provides `next/link` and `next/navigation` polyfills (original code was Next.js) -- **Login URL params** (`LoginPage.tsx`, frontend-only — the backend never sees these): besides the auth params (`ticket`/`userId`/`env`/`theme`/`yUserId`/`token`/`access_token`/`authToken`/`password`), the page supports a **task deep-link** via `?taskId=&goPath=`. After login it calls the consumer API `GET {VITE_AIPI_CONSUMER_URL}/taskAnalyseSearch/cop-task-detail?taskId=` (header `Authorization: Bearer admin`, auto-injected by `utils/request.ts`), stashes the full `data` (`id/overview/taskDirection/taskName/taskContent`) into `sessionStorage["login.copTaskDetail"]`, then routes by `goPath`: base codes `3qfx`/`rwfx`/`xdfx` → that business's configured **single agent** Q&A inside a **任务工作区** (resolved via `/api/business-mapping`; `resolveTaskDeeplinkTarget` calls `persistTaskContext({taskId, agentId, openingText})` and navigates to the `…/chats` **index**, which opens the latest task conversation or 新建 — a 1:N task↔对话 workspace, NOT `pendingAgentMessage`); the `-A` suffix (`3qfx-A`/…) → **multi-agent roundtable** with `roundtablePrefill.businessCode` so the page auto-selects the configured 业务链条. **xdfx's deep-link `?taskId=` is actually an 行动id, not a 任务id** — `resolveDeeplinkTaskId(goPath, rawId)` (called in `LoginPage` before everything) detects `xdfx`/`xdfx-A` and resolves it via `GET {action_detail_url}/{actionId}` → `data.taskId` (`fetchTaskIdFromAction`; `action_test`+`mock.action_detail` for offline testing); the resolved 任务id then drives `cop-task-detail`, the purpose tree, and the 1:N binding (other businesses pass the id through unchanged). **xdfx additionally partitions its chat records by 行动id (action_id), NOT by task_id**: `resolveTaskDeeplinkTarget` sets `partitionByAction:true` + keeps the raw action_id as `TaskContext.actionId`, so new threads are tagged `metadata.{taskId,actionId}` (taskId kept only to preserve the backend「任务深链对话全局共享」semantics, which keys off `metadata.taskId`) and the conversation list filters by `actionId` (+`agent_id`) — different actions of the same task get separate lists. xdfx's first-question opening text also appends `行动id(action_id)为…` (see `buildOpeningText` / `taskThreadMetadata`). goPath→businessCode: `3qfx`→`3Q`, `rwfx`→`6BF`, `xdfx`→`7BF`. The roundtable target also threads the URL `taskId` into `roundtablePrefill.taskId` (`resolveTaskDeeplinkTarget(goPath, detail, taskId)`), which turns on two taskId-scoped behaviors: (1) **separate, unpartitioned per-task storage** — taskId 会商聊天记录 live in their **own** store/table (`roundtable_task_drafts`), keyed **only by `task_id`** (one taskId = many records, shared across users, **no user 分权** at all; `created_by` is audit-only), kept entirely out of the per-user personal store (`roundtable_drafts`, now strictly per-user) so the two never mix. Backend router `/api/roundtable-task-drafts` (`GET /by-task/{id}` + `/by-task/{id}/latest`, `POST`, `GET/PUT/DELETE /{id}` — all user-agnostic); frontend client `roundtable-planning/api/task-drafts.ts`, and `useDraftPersistence`/`useRoundtableDrafts` route **all** CRUD to it when a `taskId` is present (else the personal `drafts.ts`). The matching **background jobs** are shared by task too: `roundtable_jobs.task_id` marks a task job; reads/dedup/stream/resume/cancel pass `?task_id=` so any user opening the task sees the same running 研讨 (progress/flowchart), and the executor writes the final report back to the task-draft store. Existing rows are moved over by `scripts/migrate_roundtable_task_drafts.py` (migration `20260618_01`). The history dropdown filters to the task and 新建 keeps the binding; (2) **Step-3 auto structured output** — after the `roundtable-summary` agent writes the Markdown report it automatically runs a second round emitting a `flow-json` flowchart (`useStep3Summary` `autoFlow`+`chainLevels`), whose hierarchy follows the business chain's stage goals (任务 → 目的 → 行为体 → …; coordinatorPrompt-specified hierarchy wins) and whose nodes never carry 阶段编号/阶段名 (the prior bug). **Both targets render embedded under the 任务管理 layout** — nested routes under `task-management//{roundtable,agent/:agent_id/chats}` in `StrategyRoutes.tsx` (`` = `plan-task-sentiment` / `plan-task/plan-task-fx` / `plan-task/plan-task-action`, matching the COH中任务 menu paths in `sidebar-menu.ts`) so the global sidebar keeps 任务管理 → COH中任务 → the matching business highlighted. **All three single-agent businesses share `TaskFxWorkspace`** (`task-components/TaskFxWorkspace.tsx`): left = a「当前任务」card (`readCopTaskDetail()` 任务名称 + 简介, desensitized, 留存 above 新建对话) + per-task 1:N conversation list (`useTaskThreads(taskId, agentId, actionId?)` — filtered by **both** `metadata.taskId` AND `metadata.agent_id` so the three businesses' conversations stay isolated even though they share one taskId; **xdfx** passes a third `actionId` so it filters by `metadata.actionId` too, partitioning chat records per 行动 — `useChatsBase` derives it from `TaskContext.partitionByAction`) + 新建对话; the `…/chats` index (`TaskFxIndexResolver`) opens the latest task thread or 新建. The right `:thread_id` host differs by business: **rwfx** → `TaskFxChat` (`AgentChatPage taskGate`) = full workspace (non-`lite`): 目的树闸门 panel + 清除记录 (placed under 新建对话); **xdfx** → also `TaskFxChat` (same 目的树闸门 query+display, same `purpose_detail_url`) but workspace passed `lite`; **3qfx** → `TaskBindChat` (`AgentChatPage taskBind`, `lite`) = 1:N list only, no gate, and 新建对话 **auto-sends** the task opening text (`taskAutoSendText`; gate businesses send it via the gate's 开始问答 instead). **rwfx auto-starts the first Q&A when the 任务目的 query returns empty** (`TaskPurposePanel autoStartWhenEmpty`, threaded from the rwfx `TaskFxChat` only), **but gated by the 3q precheck**: on entering the rwfx workspace `RwfxSqGateProvider` (`task-components/rwfx-sq-gate.tsx`, wraps the workspace, rwfx only) calls `fetchSqReportStatus(taskId)` — `incomplete`(3q 无数据) pops the「3q 未完成,是否前往 3qfx」dialog AND **holds the auto-send**; the question is only sent when the user picks「暂不」(or dismisses — i.e. does NOT go to 3q analysis), while「前往 3qfx」navigates and discards the send. `complete`/`unknown` send immediately (no dialog). The panel's auto-start calls `useRwfxSqGate().gateStart(onStart)` instead of `onStart()`; the manual「开始问答」/覆盖确认 path is unchanged (ungated). This replaced the old standalone `RwfxSqReportGate` (which popped the same dialog but did not gate the auto-send). The 目的树闸门 (`TaskPurposePanel`) defaults **collapsed** and now keeps only 弹框查看 + 开始问答; the former jump buttons (编辑/下一步/3q详情/切换三情/弹框查看) all moved to the **configurable bottom 快捷跳转 bar** (`TaskJumpBar`, red 红底白字 buttons, right-aligned) driven by 按钮管理 — see the 按钮管理 bullet. `TaskPurposeDialog` (exported from `TaskPurposePanel`) backs both the gate zoom and the bottom bar's `purpose`-type button. `chatBasePath` keeps the post-send URL rewrite + new-chat button in the task-management context. Backend config `task_deeplink` (`config.yaml`, surfaced by `GET /api/public/config/task-deeplink`, mirrored in `core/auth/task-deeplink-config.ts`) adds `action_test`/`action_detail_url`/`detail_3q_url` + `mock.action_detail`. Any failure (no consumer URL / fetch error / unmapped goPath / unresolvable action id / no configured agent) silently falls back to the default home. Helpers: `core/auth/task-deeplink.ts`, `core/auth/task-purpose.ts`, `core/auth/task-deeplink-config.ts`, `core/threads/use-task-threads.ts`. +- **Login URL params** (`LoginPage.tsx`, frontend-only — the backend never sees these): besides the auth params (`ticket`/`userId`/`env`/`theme`/`yUserId`/`token`/`access_token`/`authToken`/`password`), the page supports a **task deep-link** via `?taskId=&goPath=`. After login it calls the consumer API `GET {VITE_AIPI_CONSUMER_URL}/taskAnalyseSearch/cop-task-detail?taskId=` (header `Authorization: Bearer admin`, auto-injected by `utils/request.ts`), stashes the full `data` (`id/overview/taskDirection/taskName/taskContent`) into `sessionStorage["login.copTaskDetail"]`, then routes by `goPath`: base codes `3qfx`/`rwfx`/`xdfx` → that business's configured **single agent** Q&A inside a **任务工作区** (resolved via `/api/business-mapping`; `resolveTaskDeeplinkTarget` calls `persistTaskContext({taskId, agentId, openingText})` and navigates to the `…/chats` **index**, which opens the latest task conversation or 新建 — a 1:N task↔对话 workspace, NOT `pendingAgentMessage`); the `-A` suffix (`3qfx-A`/…) → **multi-agent roundtable** with `roundtablePrefill.businessCode` so the page auto-selects the configured 业务链条. **xdfx's deep-link `?taskId=` is actually an 行动id, not a 任务id** — `resolveDeeplinkTaskId(goPath, rawId)` (called in `LoginPage` before everything) detects `xdfx`/`xdfx-A` and resolves it via `GET {action_detail_url}/{actionId}` → `data.taskId` (`fetchTaskIdFromAction`; `action_test`+`mock.action_detail` for offline testing); the resolved 任务id then drives `cop-task-detail`, the purpose tree, and the 1:N binding (other businesses pass the id through unchanged). **xdfx additionally partitions its chat records by 行动id (action_id), NOT by task_id**: `resolveTaskDeeplinkTarget` sets `partitionByAction:true` + keeps the raw action_id as `TaskContext.actionId`, so new threads are tagged `metadata.{taskId,actionId}` (taskId kept only to preserve the backend「任务深链对话全局共享」semantics, which keys off `metadata.taskId`) and the conversation list filters by `actionId` (+`agent_id`) — different actions of the same task get separate lists. xdfx's first-question opening text also appends `行动id(action_id)为…` (see `buildOpeningText` / `taskThreadMetadata`). goPath→businessCode: `3qfx`→`3Q`, `rwfx`→`6BF`, `xdfx`→`7BF`. The roundtable target also threads the URL `taskId` into `roundtablePrefill.taskId` (`resolveTaskDeeplinkTarget(goPath, detail, taskId)`), which turns on two taskId-scoped behaviors: (1) **separate, unpartitioned per-task storage** — taskId 会商聊天记录 live in their **own** store/table (`roundtable_task_drafts`), keyed **only by `task_id`** (one taskId = many records, shared across users, **no user 分权** at all; `created_by` is audit-only), kept entirely out of the per-user personal store (`roundtable_drafts`, now strictly per-user) so the two never mix. Backend router `/api/roundtable-task-drafts` (`GET /by-task/{id}` + `/by-task/{id}/latest`, `POST`, `GET/PUT/DELETE /{id}` — all user-agnostic); frontend client `roundtable-planning/api/task-drafts.ts`, and `useDraftPersistence`/`useRoundtableDrafts` route **all** CRUD to it when a `taskId` is present (else the personal `drafts.ts`). The matching **background jobs** are shared by task too: `roundtable_jobs.task_id` marks a task job; reads/dedup/stream/resume/cancel pass `?task_id=` so any user opening the task sees the same running 研讨 (progress/flowchart), and the executor writes the final report back to the task-draft store. Existing rows are moved over by `scripts/migrate_roundtable_task_drafts.py` (migration `20260618_01`). The history dropdown filters to the task and 新建 keeps the binding; (2) **Step-3 auto structured output** — after the `roundtable-summary` agent writes the Markdown report it automatically runs a second round emitting a `flow-json` flowchart (`useStep3Summary` `autoFlow`+`chainLevels`), whose hierarchy follows the business chain's stage goals (任务 → 目的 → 行为体 → …; coordinatorPrompt-specified hierarchy wins) and whose nodes never carry 阶段编号/阶段名 (the prior bug). **Both targets render embedded under the 任务管理 layout** — nested routes under `task-management//{roundtable,agent/:agent_id/chats}` in `StrategyRoutes.tsx` (`` = `plan-task-sentiment` / `plan-task/plan-task-fx` / `plan-task/plan-task-action`, matching the COH中任务 menu paths in `sidebar-menu.ts`) so the global sidebar keeps 任务管理 → COH中任务 → the matching business highlighted. **All three single-agent businesses share `TaskFxWorkspace`** (`task-components/TaskFxWorkspace.tsx`): left = a「当前任务」card (`readCopTaskDetail()` 任务名称 + 简介, desensitized, 留存 above 新建对话) + per-task 1:N conversation list (`useTaskThreads(taskId, agentId, actionId?)` — filtered by **both** `metadata.taskId` AND `metadata.agent_id` so the three businesses' conversations stay isolated even though they share one taskId; **xdfx** passes a third `actionId` so it filters by `metadata.actionId` too, partitioning chat records per 行动 — `useChatsBase` derives it from `TaskContext.partitionByAction`) + 新建对话; the `…/chats` index (`TaskFxIndexResolver`) opens the latest task thread or 新建. The right `:thread_id` host differs by business: **rwfx** → `TaskFxChat` (`AgentChatPage taskGate`) = full workspace (non-`lite`): 目的树闸门 panel + 清除记录 (placed under 新建对话); **xdfx** → also `TaskFxChat` (same 目的树闸门 query+display, same `purpose_detail_url`) but workspace passed `lite`, **plus its own「清除记录」button** (`ActionClearButton`, under 新建对话) that deletes **by 行动id**: opening its confirm dialog first runs a「行动历史是否存在」pre-check (`fetchActionHistoryExists`; query endpoint `task_deeplink.action_history_url` is **address-TBD** — empty ⇒ unknown, does **not** block the delete; once a definite「无历史」comes back it disables 确认清除), then deletes via the **shared** delete endpoint `task_deeplink.delete_url` (migrated to `dropSixOrSevenData`, used by **both** rwfx + xdfx — Six=6BF/rwfx, Seven=7BF/xdfx): `deleteTaskPurpose(id, {idKind})` sends rwfx `?taskId=` (omits actionId) / xdfx `?actionId=` (omits taskId) — the unused param is **absent from the URL**, not null. `deleteTaskPurpose` + `fetchActionHistoryExists` live in `core/auth/task-purpose.ts`. **3qfx** → `TaskBindChat` (`AgentChatPage taskBind`, `lite`) = 1:N list only, no gate, and 新建对话 **auto-sends** the task opening text (`taskAutoSendText`; gate businesses send it via the gate's 开始问答 instead). **rwfx auto-starts the first Q&A when the 任务目的 query returns empty** (`TaskPurposePanel autoStartWhenEmpty`, threaded from the rwfx `TaskFxChat` only), **but gated by the 3q precheck**: on entering the rwfx workspace `RwfxSqGateProvider` (`task-components/rwfx-sq-gate.tsx`, wraps the workspace, rwfx only) calls `fetchSqReportStatus(taskId)` — `incomplete`(3q 无数据) pops the「3q 未完成,是否前往 3qfx」dialog AND **holds the auto-send**; the question is only sent when the user picks「暂不」(or dismisses — i.e. does NOT go to 3q analysis), while「前往 3qfx」navigates and discards the send. `complete`/`unknown` send immediately (no dialog). The panel's auto-start calls `useRwfxSqGate().gateStart(onStart)` instead of `onStart()`; the manual「开始问答」/覆盖确认 path is unchanged (ungated). This replaced the old standalone `RwfxSqReportGate` (which popped the same dialog but did not gate the auto-send). The 目的树闸门 (`TaskPurposePanel`) defaults **collapsed** and now keeps only 弹框查看 + 开始问答; the former jump buttons (编辑/下一步/3q详情/切换三情/弹框查看) all moved to the **configurable bottom 快捷跳转 bar** (`TaskJumpBar`, red 红底白字 buttons, right-aligned) driven by 按钮管理 — see the 按钮管理 bullet. `TaskPurposeDialog` (exported from `TaskPurposePanel`) backs both the gate zoom and the bottom bar's `purpose`-type button. `chatBasePath` keeps the post-send URL rewrite + new-chat button in the task-management context. Backend config `task_deeplink` (`config.yaml`, surfaced by `GET /api/public/config/task-deeplink`, mirrored in `core/auth/task-deeplink-config.ts`) adds `action_test`/`action_detail_url`/`detail_3q_url` + `mock.action_detail`. Any failure (no consumer URL / fetch error / unmapped goPath / unresolvable action id / no configured agent) silently falls back to the default home. Helpers: `core/auth/task-deeplink.ts`, `core/auth/task-purpose.ts`, `core/auth/task-deeplink-config.ts`, `core/threads/use-task-threads.ts`. - **iframe 嵌入模式 (外部系统用 iframe 套用深链问答/会商)** — 三个**按标签页**开关,全部由深链 URL 参数开启、存进同一份 `sessionStorage` 会话(`embed.session`,键名常量在 `core/embed/embed-session.ts`),跨登录跳转/刷新/页内导航存活,**绝不跨标签污染**同浏览器另开的正常系统页。核心隔离手法:用 `sessionStorage`(按标签页/浏览上下文隔离,iframe 即便同源也有独立一份)而非 `localStorage`(跨标签共享)。开关:(1) **`?embed=1`(可带 `&theme=`)** — 去全局 chrome(`PageLayout` 的 `pageSidebarAllowed && !isEmbedActive()` → 隐藏 Header + PageSidebar)+ 按外部主题色**强制**明/暗:走 next-themes 的 `forcedTheme`(`App.tsx` `AppProviders` 调 `resolveForcedEmbedTheme`,**不写** localStorage `strategy-theme`),离散映射 `dark-blue`/`dark`→深、其余→浅;嵌入侧**绝不**调 `setTheme`/`persistSidebarEnabled`(那俩落 localStorage 跨标签污染,`LoginPage` 据 `readEmbedParam` 跳过)。(2) **`?hideJump=1`** — 隐藏深链底部「快捷跳转」栏 `TaskJumpBar` 的两部分(底部「下一步」等按钮 + 右侧垂直居中悬浮菜单),单智能体问答 + 会商共用该栏,`isJumpBarHidden()` 为真时整条 `return null`。(3) **登录用户隔离** — 嵌入态下鉴权/身份键(`deerflow.auth` token、`userInfo`、`login.*`、`tools_token`、maxkey/userDetail token)全部经 `core/auth/scoped-storage.ts` 的 `authStorage()`(嵌入→sessionStorage / 正常→localStorage)读写,使 iframe 里登录的用户与正常登录用户**完全隔离**、互不覆盖;所有 token 读取都过 `getStoredAuth()`/`getAuthorizationHeaderValue()`(API 客户端 `core/api/*` 全走这俩),改中心函数即让全体调用方自动隔离;跨标签账号变更监听 `AuthSyncWatcher`(`core/auth/sync.tsx`)在嵌入态禁用。嵌入会话在 `main.tsx` 渲染前 `syncEmbedSessionFromCurrentUrl()` 同步启用(早于任何鉴权写入,避免首帧 effect 顺序漏写 localStorage);`AppProviders` 的 effect 再按 SPA 导航同步。`embed=0`/`hideJump=0` 可显式关闭。正常(非嵌入)标签页所有 `isXxx()` 恒 false,行为**零变化**。 - **操作日志(Y-Log)上报** — 用户的关键操作(登录登出/查询/查看/增删改/启停/导入导出/问答等)成功后,**前端直连**外部接口 `POST {write_url}`(默认 `https://ch1.b.uat.4.cn/api/y-log/write`)记一条审计日志。核心模块 `src/core/log/`:`y-log.ts`(`writeYLog(params)` + `OptType` 枚举 0/1/3..12;**异步即发即忘、永不 await、永不抛错、失败静默**,自动把完整 url 转相对路径去 ip/host、对象入参/返回自动 `JSON.stringify` + 50k 裁剪、`optResult` 恒 0 只报成功),`y-log-config.ts`(从公开只读 `GET /api/public/config/y-log` 拉 `{enabled, writeUrl}`,模块级缓存 + 去重 + 默认兜底;`App.tsx` `AppProviders` 启动预热 `fetchYLogConfig()`),`y-log-token.ts`(token1 = token 登录的 `?authToken=`,经 [[scoped-storage]] 的 `authStorage*` 存取,嵌入态自动隔离)。**双门控**:仅当①存在 token1(即用 token 登录,`LoginPage` 成功后 `persistYLogToken(authToken)`,登出 `account-settings-page.tsx` 先报后清 token1)且②配置 `enabled && writeUrl` 才上报;否则一律不发。`optDetail` 统一「B1-CM助手-…」(**禁内部框架名**)。埋点集中在各 API client 的成功路径:`strategy-components/api/{sensitive-words,menu-overrides,task-buttons,skill-addresses,light-apps}.ts`、`roundtable-planning/api/{drafts,task-drafts,roundtable-jobs}.ts`、`core/{agents,scheduled-tasks,knowledge}/api.ts`、`core/api/markdown-docx-export.ts`,以及问答完成钩子(`ChatPage`/`AgentChatPage` 的 `useThreadStream` `onFinish`,用最终汇总文本作 `returnParam`,optType 12)。后端仅新增公开只读配置:`deerflow/config/y_log_config.py`(`YLogConfig{enabled,write_url}`)挂到 `AppConfig.y_log`,`app/gateway/routers/public_config.py` 加 `GET /api/public/config/y-log`,地址写在 `config.yaml` 的 `y_log` 段(mtime 热重载,前端无需重建);**不做后端代理**。Tests:`tests/test_y_log_config.py`。 diff --git a/frontend-web/src/core/auth/task-deeplink-config.ts b/frontend-web/src/core/auth/task-deeplink-config.ts index be9b18ac..c0e13ad8 100644 --- a/frontend-web/src/core/auth/task-deeplink-config.ts +++ b/frontend-web/src/core/auth/task-deeplink-config.ts @@ -23,8 +23,10 @@ export interface TaskDeeplinkConfig { actionTest: boolean; /** 目的树查询完整 URL(调用追加 ?taskId=)。 */ purposeDetailUrl: string; - /** 删除完整 URL(调用追加 ?taskId=)。 */ + /** 删除完整 URL(rwfx 与 xdfx 共用同一接口):rwfx 追加 ?taskId=、xdfx 追加 ?actionId=(另一参数不传)。 */ deleteUrl: string; + /** xdfx「行动历史是否存在」查询完整 URL(调用追加 ?actionId=);留空 → 前端视作无历史、不阻断删除(地址待提供)。 */ + actionHistoryUrl: string; /** B 系统树形完整编辑页 URL(跳转追加 ?id=)。 */ treemapEditUrl: string; /** B 系统树形下一步页 URL(跳转追加 ?id=)。 */ @@ -50,10 +52,12 @@ export const DEFAULT_TASK_DEEPLINK_CONFIG: TaskDeeplinkConfig = { deleteTest: false, actionTest: false, purposeDetailUrl: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/getTaskPurposeDetail", - deleteUrl: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/delete", + deleteUrl: "https://ch1.b.uat.4.cn/bwjt/dropSixOrSevenData", treemapEditUrl: "https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-mode-treemap-full-edit", treemapNextUrl: "https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-mode-treemap-full-next", actionDetailUrl: "https://ch1.b.uat.4.cn/consumer/plan/action", + // 行动历史查询地址待提供:空字符串 → 前端不查询、视作无历史(不阻断删除)。 + actionHistoryUrl: "", detail3qUrl: "", mock: {}, }; @@ -74,6 +78,8 @@ function normalize(body: unknown): TaskDeeplinkConfig { treemapEditUrl: str(o.treemap_edit_url, d.treemapEditUrl), treemapNextUrl: str(o.treemap_next_url, d.treemapNextUrl), actionDetailUrl: str(o.action_detail_url, d.actionDetailUrl), + // action_history_url 允许为空(地址待提供):空 → 前端不查询、视作无历史。不回退默认。 + actionHistoryUrl: typeof o.action_history_url === "string" ? o.action_history_url : d.actionHistoryUrl, // detail_3q_url 允许为空(地址待定),不回退默认;只在配了非空时才生效。 detail3qUrl: typeof o.detail_3q_url === "string" ? o.detail_3q_url : d.detail3qUrl, mock: { diff --git a/frontend-web/src/core/auth/task-purpose.ts b/frontend-web/src/core/auth/task-purpose.ts index 375e3c51..2db6b42e 100644 --- a/frontend-web/src/core/auth/task-purpose.ts +++ b/frontend-web/src/core/auth/task-purpose.ts @@ -254,20 +254,35 @@ export interface DeleteTaskPurposeResult { } /** - * 删除任务目的树(覆盖前清空 / 手动清除任务记录都走这里)。``delete_test=true`` 时 - * 用后端配置里的假数据(接口未开发完时用);否则打真实接口。 + * 删除记录的「按哪种 id 删」: + * - ``task``(默认,rwfx):传 ``?taskId=``,不传 actionId; + * - ``action``(xdfx 行动分析):传 ``?actionId=``,不传 taskId。 + * rwfx 与 xdfx 共用同一个删除接口(``delete_url`` = dropSixOrSevenData); + * 「不传」= 该参数**根本不出现在 URL 里**(只把要传的那个 key 放进 params,axios 自然省略另一个), + * 不是传空值/None。 + */ +export type DeleteIdKind = "task" | "action"; + +/** + * 删除任务/行动记录(rwfx 覆盖前清空 / 手动清除 / xdfx 清除行动记录都走这里)。 + * ``delete_test=true`` 时用后端配置里的假数据(接口未开发完时用);否则打真实接口 + * (rwfx 传 ``taskId``、xdfx 传 ``actionId``,见 ``DeleteIdKind``)。 */ export async function deleteTaskPurpose( - taskId: string, + id: string, + opts?: { idKind?: DeleteIdKind }, ): Promise { - if (!taskId) return { message: "缺少 taskId", success: false }; + const idKind = opts?.idKind ?? "task"; + if (!id) return { message: idKind === "action" ? "缺少 actionId" : "缺少 taskId", success: false }; const cfg = await fetchTaskDeeplinkConfig(); if (cfg.deleteTest) { const m = cfg.mock.delete ?? {}; return { message: typeof m.message === "string" ? m.message : "", success: m.success === true }; } if (!cfg.deleteUrl) return { message: "缺少删除接口地址", success: false }; - const body = await requestCrudService.get(cfg.deleteUrl, { taskId }); + // 只放要传的那个 id key:另一参数直接不出现在 URL(axios 不会序列化缺省 key)。 + const params = idKind === "action" ? { actionId: id } : { taskId: id }; + const body = await requestCrudService.get(cfg.deleteUrl, params); const obj = body && typeof body === "object" ? (body as unknown as Record) : {}; return { @@ -276,6 +291,36 @@ export async function deleteTaskPurpose( }; } +export interface ActionHistoryResult { + /** 是否存在可删除的「行动历史」记录。 */ + exists: boolean; + /** 是否成功判定。接口未配置 / 请求失败 → false(调用方据此**不**做「无历史」拦截,仍允许删除)。 */ + known: boolean; +} + +/** + * 查询 xdfx「行动历史是否存在」(清除行动记录前的预检),按「行动id」调用。 + * + * 查询接口(``action_history_url``)地址**目前待提供**:为空时直接返回 + * ``{ exists: false, known: false }``——视作空白、不查询,也不阻断删除;后端配上真实地址后自动生效。 + * + * ⚠️ 真实返回结构待定:拿到接口后按实际字段调整「是否存在」的判定(当前暂按 ``data`` 非空兜底)。 + */ +export async function fetchActionHistoryExists(actionId: string): Promise { + if (!actionId) return { exists: false, known: false }; + const cfg = await fetchTaskDeeplinkConfig(); + if (!cfg.actionHistoryUrl) return { exists: false, known: false }; + try { + const body = await requestCrudService.get(cfg.actionHistoryUrl, { actionId }); + // TODO(接口就绪):按真实返回结构判定。暂按整包 body.data 非空作「存在历史」。 + const data = body && typeof body === "object" ? (body as { data?: unknown }).data : null; + const exists = Array.isArray(data) ? data.length > 0 : data != null && data !== ""; + return { exists, known: true }; + } catch { + return { exists: false, known: false }; + } +} + /* ----------------------------- 跳转地址 ----------------------------- */ /** B 系统「树形完整编辑」页地址(问答后「编辑」按钮,新标签打开)。用同步缓存配置。 */ diff --git a/frontend-web/src/strategy-components/components/task-components/TaskFxWorkspace.tsx b/frontend-web/src/strategy-components/components/task-components/TaskFxWorkspace.tsx index 93effc72..bc2cd190 100644 --- a/frontend-web/src/strategy-components/components/task-components/TaskFxWorkspace.tsx +++ b/frontend-web/src/strategy-components/components/task-components/TaskFxWorkspace.tsx @@ -29,7 +29,12 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { deleteTaskPurpose, readTaskContext } from "@/core/auth/task-purpose"; +import { + deleteTaskPurpose, + fetchActionHistoryExists, + readTaskContext, + type ActionHistoryResult, +} from "@/core/auth/task-purpose"; import { fetchActionDetail, readCopTaskDetail, @@ -195,6 +200,8 @@ function TaskConversationsList({ 新建对话 {!lite && } + {/* xdfx:按「行动id」清除行动记录(删除前查历史是否存在;与 rwfx 共用同一删除接口)。 */} + {business === "xdfx" && actionId && }
@@ -292,6 +299,103 @@ function TaskClearButton({ taskId }: { taskId: string }) { ); } +/** + * 「清除记录」按钮(xdfx 行动分析):放在「新建对话」下面,按「行动id」清除该行动在 B 系统里的 + * 策划数据(聊天对话保留),与 rwfx 共用同一个删除接口(仅传 ``actionId``、不传 taskId)。 + * + * 打开弹框时先「查询历史是否存在」(``fetchActionHistoryExists``): + * - 接口未就绪(地址未配置)→ ``known=false``:当作未知,仍可删除(不拦截); + * - 查到「无历史」(``known && !exists``)→ 禁用「确认清除」(没有可删的数据)。 + */ +function ActionClearButton({ actionId }: { actionId: string }) { + const [open, setOpen] = useState(false); + const [clearing, setClearing] = useState(false); + const [checking, setChecking] = useState(false); + const [history, setHistory] = useState(null); + + // 打开弹框时查历史是否存在(接口未配置 → known=false,不影响删除)。 + useEffect(() => { + if (!open) return; + let alive = true; + setChecking(true); + setHistory(null); + void fetchActionHistoryExists(actionId) + .then((r) => { + if (alive) setHistory(r); + }) + .finally(() => { + if (alive) setChecking(false); + }); + return () => { + alive = false; + }; + }, [open, actionId]); + + const handleClear = async () => { + setClearing(true); + try { + const r = await deleteTaskPurpose(actionId, { idKind: "action" }); + if (r.success) toast.success("已清除行动记录"); + else toast.error(r.message || "清除失败"); + } catch { + toast.error("清除失败,请稍后再试"); + } finally { + setClearing(false); + setOpen(false); + } + }; + + // 仅当「明确查到无历史」时禁用删除;接口未就绪(known=false)仍允许删除。 + const noHistory = history?.known === true && history.exists === false; + + return ( + <> + + + !clearing && setOpen(o)}> + + + 清除行动记录 + + 将清除该行动在 B 系统中的策划数据(聊天对话保留),是否确认? + + + {checking ? ( +
+ 正在查询历史… +
+ ) : history?.known === true ? ( +
+ {history.exists ? "检测到该行动的历史记录。" : "未检测到该行动的历史记录,无需清除。"} +
+ ) : null} + + + + +
+
+ + ); +} + /** * 任务工作区布局:左列表 + 右 Outlet。 * ``lite``(3qfx/xdfx)= 精简版:左列表无跳转按钮、无底部任务操作面板。 diff --git a/offline-backend-20260512/backend/config.yaml b/offline-backend-20260512/backend/config.yaml index 6b8593f0..ffd9bf95 100644 --- a/offline-backend-20260512/backend/config.yaml +++ b/offline-backend-20260512/backend/config.yaml @@ -431,11 +431,14 @@ task_deeplink: delete_test: true # 删除接口走 mock.delete 假数据(该接口尚未开发完) action_test: true # 行动→任务解析走 mock.action_detail 假数据(xdfx 行动id→任务id) purpose_detail_url: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/getTaskPurposeDetail" - delete_url: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/delete" + # rwfx 与 xdfx 共用同一删除接口:rwfx 追加 ?taskId=(不传 actionId);xdfx 追加 ?actionId=(不传 taskId,直接不出现)。 + delete_url: "https://ch1.b.uat.4.cn/bwjt/dropSixOrSevenData" treemap_edit_url: "https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-mode-treemap-full-edit" treemap_next_url: "https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-mode-treemap-full-next" # xdfx 深链传入的是「行动id」,先调该接口(追加 /{actionId})拿 data.taskId 解析出真正「任务id」。 action_detail_url: "https://ch1.b.uat.4.cn/consumer/plan/action" + # xdfx 删除前「行动历史是否存在」预检接口(追加 ?actionId=);地址待提供,留空则前端视作无历史、不阻断删除。 + action_history_url: "" # rwfx 目的树面板「3q详情」链接的目标页(地址待定,留空则前端提示未配置);跳转追加 ?id=任务id。 detail_3q_url: "" mock: diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/config/task_deeplink_config.py b/offline-backend-20260512/backend/packages/harness/deerflow/config/task_deeplink_config.py index 535e8faf..f86d7374 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/config/task_deeplink_config.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/config/task_deeplink_config.py @@ -8,8 +8,13 @@ - ``test`` → 目的树查询(``purpose_detail_url``)走 ``mock.purpose_detail`` 假数据; 同时 rwfx 进入「3q 是否完成」预检(``selectSqReport``,外部接口不可用)走 ``mock.sq_report`` 假数据。 - ``delete_test`` → 删除接口(``delete_url``)走 ``mock.delete`` 假数据(该接口尚未开发完时打开)。 + rwfx 与 xdfx 共用同一个删除接口 ``dropSixOrSevenData``:rwfx 传 ``?taskId=``、xdfx 传 ``?actionId=`` + (另一参数直接不传,不出现在 URL 里)。 - ``action_test`` → 行动详情接口(``action_detail_url``)走 ``mock.action_detail`` 假数据。 xdfx 深链传入的是「行动id」,需先调该接口拿 ``data.taskId`` 解析出真正的「任务id」。 + +``action_history_url`` 为 xdfx 删除前「行动历史是否存在」预检接口,地址待提供:留空则前端视作 +「无历史/不查询」,不阻断删除(后续配上真实地址即生效)。 """ from __future__ import annotations @@ -44,13 +49,17 @@ class TaskDeeplinkConfig(BaseModel): default="", description="行动详情接口基址(xdfx 行动→任务),调用时追加 /{actionId} 路径段;返回 data.taskId 为真正任务id", ) + action_history_url: str = Field( + default="", + description="xdfx「行动历史是否存在」查询完整 URL(删除前预检),调用时追加 ?actionId=;地址待提供,留空则前端视作「无历史/不查询」(不阻断删除)", + ) detail_3q_url: str = Field( default="", description="「3q详情」详情页 URL(rwfx 目的树面板的链接),跳转时追加 ?id=(任务id)", ) delete_url: str = Field( default="", - description="删除完整 URL,调用时追加 ?taskId=", + description="删除完整 URL(rwfx 与 xdfx 共用同一个删除接口 dropSixOrSevenData):rwfx 调用追加 ?taskId=(不传 actionId),xdfx 调用追加 ?actionId=(不传 taskId)——另一参数直接不出现在 URL 里,不是空值", ) treemap_edit_url: str = Field( default="", diff --git a/offline-backend-20260512/config.yaml b/offline-backend-20260512/config.yaml index 3cb025ee..ed9803d0 100644 --- a/offline-backend-20260512/config.yaml +++ b/offline-backend-20260512/config.yaml @@ -349,11 +349,14 @@ task_deeplink: delete_test: true # 删除接口走 mock.delete 假数据(该接口尚未开发完) action_test: true # 行动→任务解析走 mock.action_detail 假数据(xdfx 行动id→任务id) purpose_detail_url: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/getTaskPurposeDetail" - delete_url: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/delete" + # rwfx 与 xdfx 共用同一删除接口:rwfx 追加 ?taskId=(不传 actionId);xdfx 追加 ?actionId=(不传 taskId,直接不出现)。 + delete_url: "https://ch1.b.uat.4.cn/bwjt/dropSixOrSevenData" treemap_edit_url: "https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-mode-treemap-full-edit" treemap_next_url: "https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-mode-treemap-full-next" # xdfx 深链传入的是「行动id」,先调该接口(追加 /{actionId})拿 data.taskId 解析出真正「任务id」。 action_detail_url: "https://ch1.b.uat.4.cn/consumer/plan/action" + # xdfx 删除前「行动历史是否存在」预检接口(追加 ?actionId=);地址待提供,留空则前端视作无历史、不阻断删除。 + action_history_url: "" # rwfx 目的树面板「3q详情」链接的目标页(地址待定,留空则前端提示未配置);跳转追加 ?id=任务id。 detail_3q_url: "" mock: