diff --git a/frontend-web/src/components/workspace/agents/agent-chat-sidebar.tsx b/frontend-web/src/components/workspace/agents/agent-chat-sidebar.tsx index a87be6ba..65b504f0 100644 --- a/frontend-web/src/components/workspace/agents/agent-chat-sidebar.tsx +++ b/frontend-web/src/components/workspace/agents/agent-chat-sidebar.tsx @@ -70,6 +70,8 @@ import { } from "@/core/threads/hooks"; import type { AgentThread, AgentThreadState } from "@/core/threads/types"; import { + bootstrapResumePath, + isBootstrapThread, isThreadFavorite, isThreadPinned, sortThreadsPinnedFirst, @@ -338,14 +340,14 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) { const [renameValue, setRenameValue] = useState(""); const [shareThreadId, setShareThreadId] = useState(null); - // 排除调度/引导/会商类会话,与首页一致;置顶优先。 + // 排除调度/会商类系统会话,与首页一致;智能体创建(bootstrap)会话保留展示 + // (带机器人图标、点击回创建页);置顶优先。 const visibleThreads = useMemo( () => sortThreadsPinnedFirst( threads.filter( (thread) => thread.metadata?.thread_type !== "scheduler" && - thread.metadata?.thread_type !== "bootstrap" && thread.metadata?.thread_type !== "roundtable", ), ), @@ -450,7 +452,8 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) { ) : ( filteredThreads.map((thread) => { - const href = pathOf(thread); + const isBoot = isBootstrapThread(thread); + const href = isBoot ? bootstrapResumePath(thread.thread_id) : pathOf(thread); const isActive = href === pathname; const pinned = isThreadPinned(thread); const favorited = isThreadFavorite(thread); @@ -468,12 +471,22 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) { : "text-muted-foreground hover:bg-accent/60 hover:text-foreground", )} > - + {isBoot ? ( + + ) : ( + + )} {pinned && ( )} diff --git a/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx b/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx index 1273088c..615d6227 100644 --- a/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx +++ b/frontend-web/src/components/workspace/agents/agent-detail-panel.tsx @@ -43,6 +43,7 @@ import { import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; import { AgentQuestionEditor } from "@/components/workspace/agents/agent-question-editor"; +import { AgentRecentChats } from "@/components/workspace/agents/agent-recent-chats"; import { useArtifactsOptional } from "@/components/workspace/artifacts"; import { EnabledSkillList } from "@/components/workspace/agents/enabled-skill-list"; import { SkillPickerSheet } from "@/components/workspace/agents/skill-picker-sheet"; @@ -133,6 +134,7 @@ export function AgentDetailPanel({ onAskQuestion, disabled, defaultCollapsed, + showRecentChats, }: { agent: Agent | null | undefined; agentName: string; @@ -140,6 +142,8 @@ export function AgentDetailPanel({ disabled?: boolean; /** 初始收起(任务工作区进入时默认收起智能体信息面板)。 */ defaultCollapsed?: boolean; + /** 在面板底部展示「有关此智能体的最近对话」(仅智能体问答页开启,任务工作区不展示)。 */ + showRecentChats?: boolean; }) { const router = useRouter(); const [collapsed, setCollapsed] = useState(defaultCollapsed ?? false); @@ -794,6 +798,9 @@ export function AgentDetailPanel({ onViewDetail={setDetailSkillName} /> + + {/* ── 最近对话(右下角,仅智能体问答页展示,编辑态隐藏)──────────── */} + {showRecentChats && !editing && } ); diff --git a/frontend-web/src/components/workspace/agents/agent-recent-chats.tsx b/frontend-web/src/components/workspace/agents/agent-recent-chats.tsx new file mode 100644 index 00000000..de416639 --- /dev/null +++ b/frontend-web/src/components/workspace/agents/agent-recent-chats.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { MessageSquareIcon } from "lucide-react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +import { useThreadsPage } from "@/core/threads/hooks"; +import { pathOfThread, titleOfThread } from "@/core/threads/utils"; +import { desensitize } from "@/lib/desensitize"; +import { cn } from "@/lib/utils"; + +const RECENT_LIMIT = 8; + +/** + * 「有关此智能体的最近对话」—— 渲染在智能体详情面板底部(右下角)。 + * 按 `metadata.agent_id` 过滤出该智能体的会话,点击直接打开对应对话。 + */ +export function AgentRecentChats({ agentId }: { agentId: string }) { + const pathname = usePathname(); + const { data } = useThreadsPage({ + page: 1, + pageSize: RECENT_LIMIT, + metadata: { agent_id: agentId }, + }); + // 该智能体的会话里同样排除调度 / 引导 / 会商类系统会话。 + const threads = (data?.threads ?? []).filter( + (th) => + th.metadata?.thread_type !== "bootstrap" && + th.metadata?.thread_type !== "scheduler" && + th.metadata?.thread_type !== "roundtable", + ); + + return ( +
+
+

+ + 最近对话 +

+ + {threads.length} 个 + +
+ {threads.length === 0 ? ( +

暂无对话

+ ) : ( +
+ {threads.map((thread) => { + const href = pathOfThread(thread); + const isActive = href === pathname; + return ( + + + + {desensitize(titleOfThread(thread, "新对话"))} + + + ); + })} +
+ )} +
+ ); +} diff --git a/frontend-web/src/components/workspace/general-qa-sidebar.tsx b/frontend-web/src/components/workspace/general-qa-sidebar.tsx index d9062d86..b06e4123 100644 --- a/frontend-web/src/components/workspace/general-qa-sidebar.tsx +++ b/frontend-web/src/components/workspace/general-qa-sidebar.tsx @@ -75,8 +75,10 @@ import { } from "@/core/threads/hooks"; import type { AgentThread, AgentThreadState } from "@/core/threads/types"; import { + isBootstrapThread, isThreadFavorite, isThreadPinned, + listPathOfThread, pathOfThread, sortThreadsPinnedFirst, titleOfThread, @@ -213,11 +215,12 @@ export function GeneralQaSidebar() { const visibleThreads = useMemo( () => + // 智能体创建(bootstrap)会话也展示(带机器人图标、点击回创建页); + // 仅排除调度 / 会商类系统会话。 sortThreadsPinnedFirst( threads.filter( (thread) => thread.metadata?.thread_type !== "scheduler" && - thread.metadata?.thread_type !== "bootstrap" && thread.metadata?.thread_type !== "roundtable", ), ), @@ -249,9 +252,9 @@ export function GeneralQaSidebar() { let nextThreadPath = pathOfThread("new", { agent_id: agentIdFromPath }); if (threadIndex > -1) { if (threads[threadIndex + 1]) { - nextThreadPath = pathOfThread(threads[threadIndex + 1]!); + nextThreadPath = listPathOfThread(threads[threadIndex + 1]!); } else if (threads[threadIndex - 1]) { - nextThreadPath = pathOfThread(threads[threadIndex - 1]!); + nextThreadPath = listPathOfThread(threads[threadIndex - 1]!); } } void router.push(nextThreadPath); @@ -417,7 +420,9 @@ export function GeneralQaSidebar() { ) : ( filteredThreads.map((thread) => { - const isActive = pathOfThread(thread) === pathname; + const href = listPathOfThread(thread); + const isActive = href === pathname; + const isBoot = isBootstrapThread(thread); const pinned = isThreadPinned(thread); const favorited = isThreadFavorite(thread); return ( @@ -426,7 +431,7 @@ export function GeneralQaSidebar() { className="group/item relative flex items-center" > - + {isBoot ? ( + + ) : ( + + )} {pinned && ( )} diff --git a/frontend-web/src/components/workspace/recent-chat-list.tsx b/frontend-web/src/components/workspace/recent-chat-list.tsx index 702bcaa7..b996d0a3 100644 --- a/frontend-web/src/components/workspace/recent-chat-list.tsx +++ b/frontend-web/src/components/workspace/recent-chat-list.tsx @@ -1,6 +1,7 @@ "use client"; import { + Bot, ChevronDown, ChevronRight, Download, @@ -64,8 +65,10 @@ import { } from "@/core/threads/hooks"; import type { AgentThread, AgentThreadState } from "@/core/threads/types"; import { + isBootstrapThread, isThreadFavorite, isThreadPinned, + listPathOfThread, pathOfThread, sortThreadsPinnedFirst, titleOfThread, @@ -130,9 +133,9 @@ export function RecentChatList({ }); if (threadIndex > -1) { if (threads[threadIndex + 1]) { - nextThreadPath = pathOfThread(threads[threadIndex + 1]!); + nextThreadPath = listPathOfThread(threads[threadIndex + 1]!); } else if (threads[threadIndex - 1]) { - nextThreadPath = pathOfThread(threads[threadIndex - 1]!); + nextThreadPath = listPathOfThread(threads[threadIndex - 1]!); } } void router.push(nextThreadPath); @@ -189,11 +192,12 @@ export function RecentChatList({ [t], ); + // 智能体创建(bootstrap)会话也展示在常用对话里(带机器人图标、点击回创建页); + // 仅排除调度 / 会商类系统会话。 const visibleThreads = sortThreadsPinnedFirst( threads.filter( (thread) => thread.metadata?.thread_type !== "scheduler" && - thread.metadata?.thread_type !== "bootstrap" && thread.metadata?.thread_type !== "roundtable", ), ); @@ -239,7 +243,9 @@ export function RecentChatList({
{shownThreads.map((thread) => { - const isActive = pathOfThread(thread) === pathname; + const href = listPathOfThread(thread); + const isActive = href === pathname; + const isBoot = isBootstrapThread(thread); const pinned = isThreadPinned(thread); const favorited = isThreadFavorite(thread); return ( @@ -261,17 +267,29 @@ export function RecentChatList({ > - {compact && ( - + ) : ( + compact && ( + + ) )} {pinned && ( diff --git a/frontend-web/src/core/auth/dashboard-input.ts b/frontend-web/src/core/auth/dashboard-input.ts index 145259b4..389c4fe6 100644 --- a/frontend-web/src/core/auth/dashboard-input.ts +++ b/frontend-web/src/core/auth/dashboard-input.ts @@ -18,6 +18,7 @@ import { requestCrudService } from "@/service/request-axios-service"; import { readCopTaskDetail } from "@/core/auth/task-deeplink"; +import { fetchTaskDeeplinkConfig } from "@/core/auth/task-deeplink-config"; /** 本页输入 JSON 暂存的 sessionStorage key(拉取一次后复用,避免重复请求)。 */ export const DASHBOARD_INPUT_STORAGE_KEY = "dashboard.inputJson"; @@ -221,3 +222,43 @@ export async function fetchDashboardInputJson( if (resolved) persistDashboardInputJson(resolved); return resolved; } + +/* ---------------------- rwfx 进入预检:3q 是否完成 ---------------------- */ + +/** + * 「3q(研判情报报告)是否完成」状态: + * - ``complete`` = selectSqReport 数组里有有效内容; + * - ``incomplete`` = 数组为空 / 每条 contentJson 都是 null/空(3q 未完成); + * - ``unknown`` = 缺 consumer 地址 / 请求异常(无法判定,调用方应静默放行)。 + */ +export type SqReportStatus = "complete" | "incomplete" | "unknown"; + +/** + * rwfx(任务分析)进入时的「3q 是否完成」预检:调 selectSqReport,复用 ``extractSqReport`` + * 判定该任务是否已有有效研判情报。判定口径与大屏绘制取数完全一致(空/全 null → 无内容)。 + * - 有可用内容 → ``complete``(正常); + * - 空 / 全 null → ``incomplete``(rwfx 据此弹框引导去 3qfx); + * - 缺地址 / 请求失败 → ``unknown``(不打扰用户)。 + * + * **数据源由后端配置 ``task_deeplink.test`` 决定**(线上 selectSqReport 外部不可用时): + * - ``test=true`` → 直接用 ``config.yaml`` 的 ``mock.sq_report`` 假数据判定,**不发请求** + * (与目的树查询/行动解析的 mock 同一套机制,见 [[task-deeplink-config]]); + * - ``test=false`` → 调真实接口 ``GET {VITE_AIPI_CONSUMER_URL}/cop/selectSqReport?taskId=``。 + */ +export async function fetchSqReportStatus(taskId: string): Promise { + if (!taskId) return "unknown"; + const cfg = await fetchTaskDeeplinkConfig(); + // test=true:外部接口不可用,直接用后端 config 的假数据判定(不打真实接口)。 + if (cfg.test) { + return extractSqReport(cfg.mock.sqReport, taskId) ? "complete" : "incomplete"; + } + // 真实模式:打真实接口。service 拦截器统一注入鉴权并返回整包 body;空/全 null → null。 + const url = sqReportUrl(); + if (!url) return "unknown"; + try { + const body = await requestCrudService.get(url, { taskId }); + return extractSqReport(body, taskId) ? "complete" : "incomplete"; + } catch { + return "unknown"; + } +} diff --git a/frontend-web/src/core/auth/task-deeplink-config.ts b/frontend-web/src/core/auth/task-deeplink-config.ts index 86d5b716..be9b18ac 100644 --- a/frontend-web/src/core/auth/task-deeplink-config.ts +++ b/frontend-web/src/core/auth/task-deeplink-config.ts @@ -34,11 +34,13 @@ export interface TaskDeeplinkConfig { /** 「3q详情」详情页 URL(rwfx 目的树面板链接,跳转追加 ?id= 任务id);留空则前端提示未配置。 */ detail3qUrl: string; /** 测试假数据:purposeDetail = 原始 {state,msg,data};delete = {message,success}; - * actionDetail = 原始 {state,msg,data:{taskId,...}}。 */ + * actionDetail = 原始 {state,msg,data:{taskId,...}};sqReport = 原始 {state,msg,data:[...]} + * (rwfx 3q 预检用,test 为真时生效)。 */ mock: { purposeDetail?: unknown; delete?: { message?: string; success?: boolean }; actionDetail?: unknown; + sqReport?: unknown; }; } @@ -81,6 +83,7 @@ function normalize(body: unknown): TaskDeeplinkConfig { ? (mock.delete as { message?: string; success?: boolean }) : undefined, actionDetail: mock.action_detail, + sqReport: mock.sq_report, }, }; } diff --git a/frontend-web/src/core/threads/utils.ts b/frontend-web/src/core/threads/utils.ts index 7a3d8769..adc0ded4 100644 --- a/frontend-web/src/core/threads/utils.ts +++ b/frontend-web/src/core/threads/utils.ts @@ -81,6 +81,32 @@ export function titleOfThread(thread: AgentThread, fallback = "Untitled") { return fallback; } +/** + * 智能体创建过程产生的「引导会话」(bootstrap)。这类会话用 metadata.thread_type + * === "bootstrap" 标记,点击后应回到「创建智能体」页继续,而不是普通问答页。 + */ +export function isBootstrapThread(thread: { + metadata?: Record | null; +}): boolean { + return thread.metadata?.thread_type === "bootstrap"; +} + +/** + * bootstrap 会话点击后回到「创建智能体」页并恢复该会话。该页只挂在 + * /page/workspace 下(见 WorkspaceRoutes 的 agents/new/:thread_id),故路径固定, + * 不随当前 route base 变化。 + */ +export function bootstrapResumePath(threadId: string): string { + return `/page/workspace/agents/new/${threadId}`; +} + +/** 列表项点击目标:bootstrap 会话回创建页恢复,其余走普通问答路由。 */ +export function listPathOfThread(thread: AgentThread): string { + return isBootstrapThread(thread) + ? bootstrapResumePath(thread.thread_id) + : pathOfThread(thread); +} + export function isThreadPinned(thread: AgentThread): boolean { return (thread.metadata as Record | null)?.is_pinned === true; } diff --git a/frontend-web/src/pages/AgentChatPage.tsx b/frontend-web/src/pages/AgentChatPage.tsx index b8da2890..659507ba 100644 --- a/frontend-web/src/pages/AgentChatPage.tsx +++ b/frontend-web/src/pages/AgentChatPage.tsx @@ -469,6 +469,7 @@ export default function AgentChatPage({ disabled={thread.isLoading || isDemoMode} onAskQuestion={(text) => handleSubmit({ text, files: [] })} defaultCollapsed={taskCtxOn} + showRecentChats={!taskCtxOn} />
diff --git a/frontend-web/src/pages/NewAgentPage.tsx b/frontend-web/src/pages/NewAgentPage.tsx index 0906cd7c..bbe05b4d 100644 --- a/frontend-web/src/pages/NewAgentPage.tsx +++ b/frontend-web/src/pages/NewAgentPage.tsx @@ -7,7 +7,7 @@ import { SaveIcon, SearchIcon, } from "lucide-react"; -import { useRouter } from "next/navigation"; +import { useParams, useRouter } from "next/navigation"; import { useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; @@ -76,7 +76,11 @@ function getCreateAgentErrorMessage( export default function NewAgentPage() { const { t } = useI18n(); const router = useRouter(); - const threadId = useMemo(() => uuid(), []); + // 从「常用对话」点击某条智能体创建(bootstrap)会话回到本页时,URL 带 thread_id, + // 用它恢复该会话(加载历史消息、继续对话);正常进入则新开一个会话。 + const { thread_id: resumeThreadId } = useParams<{ thread_id?: string }>(); + const generatedThreadId = useMemo(() => uuid(), []); + const threadId = resumeThreadId || generatedThreadId; const [nameInput, setNameInput] = useState(""); const [descriptionInput, setDescriptionInput] = useState(""); @@ -93,7 +97,8 @@ export default function NewAgentPage() { const [isSkillSelectionTouched, setIsSkillSelectionTouched] = useState(false); const [detailSkillName, setDetailSkillName] = useState(null); const [skillSearch, setSkillSearch] = useState(""); - const [threadPrepared, setThreadPrepared] = useState(false); + // 恢复已有 bootstrap 会话时该 thread 已存在,无需再 create(置为 true,跳过创建)。 + const [threadPrepared, setThreadPrepared] = useState(!!resumeThreadId); const { skills, isLoading: isSkillsLoading } = useSkills(); const { models, isLoading: isModelsLoading } = useModels(); diff --git a/frontend-web/src/pages/WorkspaceRoutes.tsx b/frontend-web/src/pages/WorkspaceRoutes.tsx index 4810ed01..66d5dce4 100644 --- a/frontend-web/src/pages/WorkspaceRoutes.tsx +++ b/frontend-web/src/pages/WorkspaceRoutes.tsx @@ -57,6 +57,12 @@ function NotebookDetailPageWithKey() { return ; } +// 恢复某条 bootstrap 会话时按 thread_id 重挂,切换会话不串台。 +function NewAgentPageWithKey() { + const { thread_id } = useParams<{ thread_id: string }>(); + return ; +} + function isInIframe(): boolean { try { return window.self !== window.top; @@ -279,6 +285,16 @@ export default function WorkspaceRoutes({ } /> + {/* 从「常用对话」点击智能体创建(bootstrap)会话回到创建页并恢复该会话。 + key 绑定 thread_id —— 切换不同 bootstrap 会话时重挂一个干净的页面。 */} + + + + } + /> } 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 a8f699f0..2a3fb8c6 100644 --- a/frontend-web/src/strategy-components/components/task-components/TaskFxWorkspace.tsx +++ b/frontend-web/src/strategy-components/components/task-components/TaskFxWorkspace.tsx @@ -30,7 +30,13 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { deleteTaskPurpose, readTaskContext } from "@/core/auth/task-purpose"; -import { fetchActionDetail, readCopTaskDetail, type ActionDetail } from "@/core/auth/task-deeplink"; +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"; @@ -276,6 +282,79 @@ 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)= 精简版:左列表无跳转按钮、无底部任务操作面板。 @@ -305,6 +384,8 @@ export function TaskFxWorkspace({ + {/* rwfx 专属:进入时预检 3q 是否完成,未完成则弹框引导去 3qfx。 */} + {business === "rwfx" && } ); } diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index efe0577d..c6715a5e 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -393,7 +393,10 @@ query-time type, `recommended_question`. Sync only ever adds/removes `origin='position'` rows, so a user's own favorites/subscriptions are never clobbered. Default sets (no binding for a type): agents = built-ins (`user_id IS NULL`); skills = empty (square stays -full, nothing added to 我的); scheduled tasks = empty; questions = unrestricted. +full, nothing added to 我的); scheduled tasks = empty; questions = **untagged +only** (默认岗位 / 无标签绑定 只看到未打标的题目;打标的题目只在绑定了对应标签的 +岗位展示 — the router interprets `resolve_visible → None` as "show only untagged", +not "show everything"). `origin`/`position_id` columns were added to `agent_favorites` and `scheduled_task_subscriptions` — see migration `20260616_01` + `scripts/sql/column_additions_mysql.sql`. The running app also auto-adds these @@ -415,8 +418,12 @@ unassigned users inherit, else `null`); powers the top-bar 岗位 label changes: `GET /api/recommended-questions` is now filtered by the caller's position (`?all=1` bypasses for admin management) and admin can tag questions via `/api/tags/assignments/recommended_question/{id}`. -Each listed question now carries its assigned `tags` (bulk-attached via -`tag_store.list_assignments`), and the list accepts a repeatable `tag_ids` +**Default-position rule**: tags are bulk-attached to **all** questions first, +then the visibility filter applies — a position binding recommended_question 标签 +sees only those tagged questions; a 默认岗位 / 无绑定 (`resolve_visible → None`) +sees only the **untagged** questions (打标的题目不再泄漏到默认岗位). `?all=1` +(admin management) skips this filter and returns every question with its tags. +The list also accepts a repeatable `tag_ids` OR-filter — so the 推荐问题配置 page can display per-question 标签 and search by them (frontend `RecommendedQuestionsPage` + `TagSearchBar`). Tests: `tests/test_recommended_questions_tags.py`. diff --git a/offline-backend-20260512/backend/app/gateway/routers/recommended_questions.py b/offline-backend-20260512/backend/app/gateway/routers/recommended_questions.py index a267c901..cb0e5591 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/recommended_questions.py +++ b/offline-backend-20260512/backend/app/gateway/routers/recommended_questions.py @@ -67,24 +67,11 @@ async def list_recommended_questions( ) -> RecommendedQuestionsListResponse: store = _get_store(request) rows = await store.list_all() - if not all: - # Filter by the caller's 岗位 (position) visibility. ``None`` = - # unrestricted (no skill/question tag binding → show all default - # questions); a set = show only the questions carrying the bound tags. - sync = getattr(request.app.state, "position_sync", None) - if sync is not None: - try: - visible = await sync.resolve_visible(_current_user_id(request), "recommended_question") - except Exception: - logger.debug("recommended-question visibility resolve failed", exc_info=True) - visible = None - if visible is not None: - rows = [r for r in rows if r["id"] in visible] responses = [RecommendedQuestionResponse(**r) for r in rows] # Bulk-attach the tags assigned to each question so the management list can - # display them and filter by 标签. Best-effort: a missing/failing tag store - # never breaks the question list. + # display them and the 岗位 filter below can tell tagged from untagged. + # Best-effort: a missing/failing tag store never breaks the question list. tag_store = getattr(request.app.state, "tag_store", None) if tag_store is not None and responses: try: @@ -95,6 +82,24 @@ async def list_recommended_questions( for resp in responses: resp.tags = assignments.get(resp.id, []) + if not all: + # Filter by the caller's 岗位 (position) visibility: + # * ``set`` — the position binds recommended_question 标签 → show only + # the questions carrying those bound tags. + # * ``None`` — 默认岗位 / 无标签绑定 → show only **untagged** questions. + # 打标的题目只在绑定了对应标签的岗位展示,不再泄漏到默认岗位。 + sync = getattr(request.app.state, "position_sync", None) + if sync is not None: + try: + visible = await sync.resolve_visible(_current_user_id(request), "recommended_question") + except Exception: + logger.debug("recommended-question visibility resolve failed", exc_info=True) + visible = None + if visible is not None: + responses = [r for r in responses if r.id in visible] + else: + responses = [r for r in responses if not r.tags] + if tag_ids: wanted = set(tag_ids) responses = [r for r in responses if any(t.get("id") in wanted for t in r.tags)] 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 e5089c2a..535e8faf 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 @@ -5,7 +5,8 @@ 随时改 ``config.yaml`` 即可(Gateway 有 mtime 热重载,无需重新构建前端)。 测试开关(线上接口未通时用假数据,互相独立): - - ``test`` → 目的树查询(``purpose_detail_url``)走 ``mock.purpose_detail`` 假数据。 + - ``test`` → 目的树查询(``purpose_detail_url``)走 ``mock.purpose_detail`` 假数据; + 同时 rwfx 进入「3q 是否完成」预检(``selectSqReport``,外部接口不可用)走 ``mock.sq_report`` 假数据。 - ``delete_test`` → 删除接口(``delete_url``)走 ``mock.delete`` 假数据(该接口尚未开发完时打开)。 - ``action_test`` → 行动详情接口(``action_detail_url``)走 ``mock.action_detail`` 假数据。 xdfx 深链传入的是「行动id」,需先调该接口拿 ``data.taskId`` 解析出真正的「任务id」。 @@ -25,7 +26,7 @@ class TaskDeeplinkConfig(BaseModel): test: bool = Field( default=False, - description="为真时前端目的树查询走 mock.purpose_detail 假数据(不打真实接口)", + description="为真时前端目的树查询走 mock.purpose_detail 假数据;同时 rwfx 进入「3q 是否完成」预检(selectSqReport)走 mock.sq_report 假数据(都不打真实接口)", ) delete_test: bool = Field( default=False, @@ -61,5 +62,5 @@ class TaskDeeplinkConfig(BaseModel): ) mock: dict[str, Any] = Field( default_factory=dict, - description="test/delete_test/action_test 为真时返回的假数据:{purpose_detail: {...}, delete: {message, success}, action_detail: {state, msg, data: {taskId, ...}}}", + description="test/delete_test/action_test 为真时返回的假数据:{purpose_detail: {...}, delete: {message, success}, action_detail: {state, msg, data: {taskId, ...}}, sq_report: {state, msg, data: [...]}}(sq_report 供 rwfx 3q 预检,test 为真时用)", ) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/services/position_sync.py b/offline-backend-20260512/backend/packages/harness/deerflow/services/position_sync.py index bc3ee1e1..2a6e86e8 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/services/position_sync.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/services/position_sync.py @@ -16,8 +16,11 @@ Resolution rule (per resource type): - ``scheduled_task`` → empty (no built-in tasks) - ``skill`` → empty (the 广场 still shows every visible skill; a position only *adds* its tagged skills to the member's "我的技能") - - ``recommended_question`` → *unrestricted* (handled at query time, never - materialized — see :meth:`resolve_visible`) + - ``recommended_question`` → *untagged-only* (handled at query time, never + materialized — see :meth:`resolve_visible`). The default set is the + questions carrying **no** 标签; tagged questions surface only for a position + that binds their tag. (The recommended-questions router interprets a + ``None`` from :meth:`resolve_visible` as "show only untagged".) Materialization (``sync_user``) only ever touches rows it created itself (``origin='position'``), so a user's own favorites / subscriptions are never @@ -67,12 +70,14 @@ class PositionSyncService: return await self._positions.get_default_position_id() async def resolve_visible(self, user_id: str, resource_type: str) -> set[str] | None: - """Return the set of visible ``resource_type`` ids, or ``None`` = unrestricted. + """Return the set of visible ``resource_type`` ids, or ``None`` = no tag binding. Used by the recommended-questions filter (the only remaining query-time - visibility type; skills are now *materialized*, not filtered). ``None`` - means "no position restriction → show everything"; a set means "restrict - to exactly these ids". + visibility type; skills are now *materialized*, not filtered). A set + means "restrict to exactly these ids". ``None`` means the effective + position binds no tag for this type — the recommended-questions router + treats that as "默认岗位 → show only untagged questions" (see + ``app/gateway/routers/recommended_questions.py``). """ pid = await self._effective_position_id(user_id) if not pid: diff --git a/offline-backend-20260512/backend/tests/test_recommended_questions_tags.py b/offline-backend-20260512/backend/tests/test_recommended_questions_tags.py index f4d19e78..054d9225 100644 --- a/offline-backend-20260512/backend/tests/test_recommended_questions_tags.py +++ b/offline-backend-20260512/backend/tests/test_recommended_questions_tags.py @@ -77,3 +77,65 @@ async def test_list_without_tag_store_does_not_break() -> None: assert [q.id for q in resp.questions] == ["q1", "q2"] assert all(q.tags == [] for q in resp.questions) + + +class _FakeSync: + """Stub position-sync whose ``resolve_visible`` returns a fixed value. + + ``None`` mimics 默认岗位 / 无标签绑定; a set mimics a position that binds + recommended_question 标签. + """ + + def __init__(self, visible: set[str] | None) -> None: + self._visible = visible + + async def resolve_visible(self, _user_id, _resource_type): # noqa: ANN001 + return self._visible + + +@pytest.mark.asyncio +async def test_default_position_shows_only_untagged() -> None: + """默认岗位(resolve_visible → None)只看到未打标的题目。""" + tag_store = MemoryTagStore() + tag = await tag_store.create_tag( + {"id": "t1", "name": "理赔", "tag_type": "recommended_question"} + ) + await tag_store.set_target_tags("recommended_question", "q2", [tag["id"]]) + + req = _request(_FakeQuestionStore(_rows()), tag_store) + req.app.state.position_sync = _FakeSync(None) + resp = await rq_router.list_recommended_questions(req, all=False) + + assert [q.id for q in resp.questions] == ["q1"] + + +@pytest.mark.asyncio +async def test_bound_position_shows_only_its_tagged_questions() -> None: + """绑定了标签的岗位(resolve_visible → set)只看到对应打标的题目。""" + tag_store = MemoryTagStore() + tag = await tag_store.create_tag( + {"id": "t1", "name": "理赔", "tag_type": "recommended_question"} + ) + await tag_store.set_target_tags("recommended_question", "q2", [tag["id"]]) + + req = _request(_FakeQuestionStore(_rows()), tag_store) + req.app.state.position_sync = _FakeSync({"q2"}) + resp = await rq_router.list_recommended_questions(req, all=False) + + assert [q.id for q in resp.questions] == ["q2"] + + +@pytest.mark.asyncio +async def test_admin_view_ignores_position_filter() -> None: + """``all=True`` 管理视图照旧返回全部(含打标),不受岗位过滤影响。""" + tag_store = MemoryTagStore() + tag = await tag_store.create_tag( + {"id": "t1", "name": "理赔", "tag_type": "recommended_question"} + ) + await tag_store.set_target_tags("recommended_question", "q2", [tag["id"]]) + + req = _request(_FakeQuestionStore(_rows()), tag_store) + req.app.state.position_sync = _FakeSync(None) + resp = await rq_router.list_recommended_questions(req, all=True) + + assert [q.id for q in resp.questions] == ["q1", "q2"] diff --git a/offline-backend-20260512/config.yaml b/offline-backend-20260512/config.yaml index 5c70686c..3fd5da8e 100644 --- a/offline-backend-20260512/config.yaml +++ b/offline-backend-20260512/config.yaml @@ -331,7 +331,7 @@ summarization: # ?taskId= / ?id=),随时改这里即可(Gateway mtime 热重载,前端无需重建)。 task_deeplink: # 线上接口未通时用假数据;三个开关互相独立。 - test: true # 目的树查询走 mock.purpose_detail 假数据 + test: true # 目的树查询走 mock.purpose_detail 假数据;同时 rwfx 进入「3q 是否完成」预检走 mock.sq_report 假数据 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" @@ -382,3 +382,15 @@ task_deeplink: actionName: "" actionStatus: 25 narration: "" + # test=true 时 rwfx 进入「3q 是否完成」预检(selectSqReport,外部不可用)走这份假数据。 + # 结构同真实 GET /consumer/cop/selectSqReport?taskId= 返回;前端判定 data 为空 / 每条 + # contentJson 都是 null/空 = 「3q 未完成」→ 弹框引导去 3qfx;否则视为已完成(正常,不弹框)。 + # 默认给一条有内容的数据 = 已完成(不打扰)。要测「3q 未完成」弹框:把下面 data 改成 []。 + sq_report: + state: "200" + msg: "操作成功!" + data: + - id: "sq-mock-1" + taskId: 3338 + categoryType: "敌情关键事件 JSON" + contentJson: "[{\"topic\":\"示例研判事件\",\"count\":1}]"