From 786ddaa38fadabbc4826cf1c472077a3ed8368e7 Mon Sep 17 00:00:00 2001 From: fengxiangdebaozi <10845145+fengxiangdebaozi@user.noreply.gitee.com> Date: Fri, 29 May 2026 18:07:45 +0800 Subject: [PATCH 01/28] =?UTF-8?q?=E4=BB=A3=E7=A0=81=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend-web/src/components/page-sdebar.css | 135 +++++++++++----- .../src/components/page-sidebar-dark.tsx | 150 +++++++++--------- .../components/ai-writing/AIWritingPanel.tsx | 16 +- .../ai-writing/SkillSelectStreamingView.tsx | 117 ++++++++------ .../components/ai-writing/SkillStepsCard.tsx | 4 +- .../src/open-canvas/types/ai-writing.ts | 3 +- frontend-web/src/pages/PageRoutes.tsx | 14 +- frontend-web/src/pages/WorkspaceRoutes.tsx | 16 +- .../agents/ai_writing/search/skill_search.py | 11 ++ 9 files changed, 293 insertions(+), 173 deletions(-) diff --git a/frontend-web/src/components/page-sdebar.css b/frontend-web/src/components/page-sdebar.css index de2edd84..f8b264dd 100644 --- a/frontend-web/src/components/page-sdebar.css +++ b/frontend-web/src/components/page-sdebar.css @@ -1,6 +1,16 @@ +.sidebar-contnet { + position: fixed; + left: 0; + top: 4rem; + z-index: 40; + width: 98px; + height: calc(100vh - 4rem); + flex-shrink: 0; +} + .sidebar-contents { width: 98px; - min-height: 986px; + min-height: calc(100% - 20px); background: rgba(3, 38, 80, 0.8); border: 1px solid; border-color: rgba(43, 143, 254, 0.6); @@ -11,16 +21,30 @@ align-items: center; padding: 12px 10px; position: relative; + height: calc(100% - 20px); + box-sizing: border-box; + transform: translate(10px, 10px); +} + +.item-nav-group { + width: 100%; +} + +.item-nav-wrap { + position: relative; + width: 100%; +} + +.item-nav-group:first-child .item-nav { + margin-top: 0; } .item-nav { width: 100%; height: 70px; - background: #ffffff1a; - border-color: #fefafa4d; margin-top: 13px; font-family: Noto Sans S Chinese; - font-weight: 550; + font-weight: 500; color: #fff; font-size: 20px; display: flex; @@ -35,9 +59,46 @@ position: relative; } -.item-nav:hover { - background: rgba(85, 166, 254, 0.12); +.item-nav:hover, +.item-nav-active, +.item-nav-hovered { color: #ffffff; + background: linear-gradient(134.25deg, + rgba(0, 98, 217, 0.59) 2.28%, + rgba(0, 98, 217, 0.3) 99.59%) !important; + background-size: cover; + border: 1px solid; + border-color: rgba(255, 255, 255, 0.2); + position: relative; +} + +.item-nav:hover::before, +.item-nav-active::before, +.item-nav-hovered::before { + content: ''; + position: absolute; + right: -10px; + top: 50%; + transform: translateY(-50%); + width: 0; + height: 0; + border-top: 8px solid transparent; + border-bottom: 8px solid transparent; + border-left: 10px solid rgba(0, 98, 217, 0.45); + pointer-events: none; +} + +.item-nav:hover::after, +.item-nav-active::after, +.item-nav-hovered::after { + content: ''; + position: absolute; + left: 0; + top: 24px; + width: 77px; + height: 45px; + background: url('../assets/icon/nav-line-logo.svg') no-repeat; + background-size: cover; } .item-nav__label { @@ -52,53 +113,47 @@ .item-nav-content { width: 155px; - min-height: 970px; + height: calc(100vh - 4rem - 20px); + min-height: calc(100vh - 4rem - 20px); background: rgba(3, 38, 80, 0.8); border: 1px solid; border-color: rgba(43, 143, 254, 0.6); border-radius: 4px; backdrop-filter: blur(2px); position: absolute; - left: 97px; + left: 108px; top: 0; z-index: 1000; padding: 14px 13px 18px 16px; overflow-y: auto; -} - -.item-nav-active { - color: #ffffff; - background: linear-gradient(134.25deg, - rgba(0, 98, 217, 0.59) 2.28%, - rgba(0, 98, 217, 0.3) 99.59%) !important; - background-size: cover; - border: 1px solid; - border-color: rgba(255, 255, 255, 0.2); - position: relative; -} - -.item-nav-active::before { - content: ''; - position: absolute; - right: -10px; - top: 50%; - transform: translateY(-50%); - width: 0; - height: 0; - border-top: 8px solid transparent; - border-bottom: 8px solid transparent; - border-left: 10px solid rgba(0, 98, 217, 0.45); + opacity: 0; + visibility: hidden; pointer-events: none; + transition: opacity 0.2s ease, visibility 0.2s ease; } -.item-nav-active::after { + +.item-nav-content::before { content: ''; position: absolute; - left: 0; - top: 24px; - width: 77px; - height: 45px; - background: url('../assets/icon/nav-line-logo.svg') no-repeat; - background-size: cover; + left: -30px; + top: 0; + width: 30px; + height: 100%; +} + +.item-nav-hover-bridge { + position: absolute; + right: -30px; + top: 0; + width: 30px; + height: 100%; + z-index: 999; +} + +.item-nav-content--visible { + opacity: 1; + visibility: visible; + pointer-events: auto; } .sidebar-flyout-list { @@ -140,7 +195,7 @@ padding: 3px 0; font-family: Noto Sans S Chinese; font-size: 18px; - line-height: 28px; + line-height: 16px; font-weight: 500; letter-spacing: 0; color: rgba(255, 255, 255, 0.8); diff --git a/frontend-web/src/components/page-sidebar-dark.tsx b/frontend-web/src/components/page-sidebar-dark.tsx index aa18608f..17238017 100644 --- a/frontend-web/src/components/page-sidebar-dark.tsx +++ b/frontend-web/src/components/page-sidebar-dark.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import type { ElementType } from "react"; import { useNavigate, useLocation } from "react-router-dom"; import { @@ -37,18 +37,7 @@ import { Wrench, } from "lucide-react"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { NotificationDialog } from "@/components/workspace/notification-dialog"; -import { SettingsDialog } from "@/components/workspace/settings"; -import { useTheme } from "@/contexts/ThemeContext"; import { getStoredAuth } from "@/core/auth"; -import { useI18n } from "@/core/i18n/hooks"; -import { useUnreadCount } from "@/core/notifications"; import './page-sdebar.css' type MenuItem = { @@ -60,13 +49,6 @@ type MenuItem = { children?: MenuItem[]; }; -type PageSidebarDarkProps = { - collapsed: boolean; - onToggleCollapsed: () => void; -}; - -type SettingsSection = "account" | "appearance" | "memory" | "tools" | "about"; - const menuItems: MenuItem[] = [ { id: "qa-center", @@ -158,9 +140,9 @@ const menuItems: MenuItem[] = [ label: "PG中任务", icon: Folder, children: [ - { id: "pg-task-pg", label: "任务PG", icon: ListTodo }, - { id: "pg-task-xdpg", label: "XDPG", icon: ListTodo }, - { id: "pg-task-info-pg", label: "信息PG", icon: ListTodo }, + { id: "pg-task-pg", label: "任务PG", icon: ListTodo, path: "/page/strategy/task-management/effect-task-pg" }, + { id: "pg-task-xdpg", label: "XDPG", icon: ListTodo, path: "/page/strategy/task-management/effect-action-pg" }, + { id: "pg-task-info-pg", label: "信息PG", icon: ListTodo, path: "/page/strategy/task-management/effect-info-pg" }, ], }, { @@ -168,9 +150,9 @@ const menuItems: MenuItem[] = [ label: "TY中任务", icon: Folder, children: [ - { id: "ty-task-xdty", label: "XDTY任务", icon: ListTodo }, - { id: "ty-task-plan", label: "方案TY任务", icon: ListTodo }, - { id: "ty-task-xxty", label: "XXTY任务", icon: ListTodo }, + { id: "ty-task-xdty", label: "XDTY任务", icon: ListTodo, path: "/page/strategy/task-management/mnty-task-mechanism" }, + { id: "ty-task-plan", label: "方案TY任务", icon: ListTodo, path: "/page/strategy/task-management/mnty-task-plan" }, + { id: "ty-task-xxty", label: "XXTY任务", icon: ListTodo, path: "/page/strategy/task-management/mnty-task-zsty" }, ], }, ], @@ -278,35 +260,49 @@ function filterMenuItemsByRole(items: MenuItem[], isAdmin: boolean): MenuItem[] ); } -export function PageSidebarDark({ - collapsed, - onToggleCollapsed, -}: PageSidebarDarkProps) { - const { theme } = useTheme(); - const isDark = theme === "dark"; +export function PageSidebarDark() { const isAdmin = getStoredAuth()?.system_role === "admin"; const visibleMenuItems = useMemo( () => filterMenuItemsByRole(menuItems, !!isAdmin), [isAdmin], ); - const { t } = useI18n(); - const { data: unreadCount = 0 } = useUnreadCount(); const [expandedIds, setExpandedIds] = useState>(defaultExpandedIds); - const [settingsOpen, setSettingsOpen] = useState(false); - const [settingsDefaultSection, setSettingsDefaultSection] = - useState("appearance"); - const [notificationOpen, setNotificationOpen] = useState(false); - const [mounted, setMounted] = useState(false); const navigate = useNavigate(); const { pathname } = useLocation(); + // pathname 变化时取消任何未触发的 hover 关闭计时(cleanup 在卸载时也跑) useEffect(() => { - setMounted(true); + return () => { + if (hoverCloseTimerRef.current !== null) { + window.clearTimeout(hoverCloseTimerRef.current); + } + }; }, []); - const openSettings = (section: SettingsSection) => { - setSettingsDefaultSection(section); - setSettingsOpen(true); + const handleNavHoverEnter = (id: string) => { + if (hoverCloseTimerRef.current !== null) { + window.clearTimeout(hoverCloseTimerRef.current); + hoverCloseTimerRef.current = null; + } + setHoveredTopId(id); + }; + + const scheduleNavPanelClose = () => { + if (hoverCloseTimerRef.current !== null) { + window.clearTimeout(hoverCloseTimerRef.current); + } + hoverCloseTimerRef.current = window.setTimeout(() => { + setHoveredTopId(null); + hoverCloseTimerRef.current = null; + }, 180); + }; + + const closeNavPanel = () => { + if (hoverCloseTimerRef.current !== null) { + window.clearTimeout(hoverCloseTimerRef.current); + hoverCloseTimerRef.current = null; + } + setHoveredTopId(null); }; const hasActiveChild = (item: MenuItem): boolean => { @@ -317,18 +313,21 @@ export function PageSidebarDark({ return item.children?.some(hasActiveChild) ?? false; }; - const initialActiveTopItem = visibleMenuItems.find(hasActiveChild) ?? visibleMenuItems[0]; - const [activeTopId, setActiveTopId] = useState(initialActiveTopItem?.id ?? "qa-center"); + // lazy init:仅在首次 render 计算一次 active id;之后由下面的 useEffect 跟随 pathname 同步 + const [activeTopId, setActiveTopId] = useState( + () => visibleMenuItems.find(hasActiveChild)?.id ?? visibleMenuItems[0]?.id ?? "qa-center", + ); + const [hoveredTopId, setHoveredTopId] = useState(null); + const hoverCloseTimerRef = useRef(null); useEffect(() => { const matchedTopItem = visibleMenuItems.find(hasActiveChild); if (matchedTopItem) { setActiveTopId(matchedTopItem.id); } + closeNavPanel(); }, [pathname, visibleMenuItems]); - const activeTopItem = visibleMenuItems.find((item) => item.id === activeTopId) ?? initialActiveTopItem; - const toggleExpanded = (id: string) => { setExpandedIds((prev) => { const next = new Set(prev); @@ -345,6 +344,7 @@ export function PageSidebarDark({ setActiveTopId(item.id); if (!item.children?.length && item.path) { + closeNavPanel(); navigate(item.path); } }; @@ -356,6 +356,7 @@ export function PageSidebarDark({ } if (item.path) { + closeNavPanel(); navigate(item.path); } }; @@ -387,34 +388,37 @@ export function PageSidebarDark({ ); return ( - <> - - - {/* TODO: 在这里编写深色主题的侧边栏 HTML 结构 */} -
-
-
- {activeTopItem?.children?.length ? renderFlyoutMenu(activeTopItem.children) : null} +
- + ); } diff --git a/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx b/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx index 9efa88b2..b319a573 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx @@ -448,8 +448,15 @@ export function AIWritingPanel({ /> )} - {/* 技能选择 LLM 流式打字(含 思考分离),select_skills 完成后自动清空 */} - {state.status === 'researching' && (state.skillSelectStreamingText || state.skillSelectThinkingText) && ( + {/* 技能选择 LLM 流式打字(含 思考分离),select_skills 完成后自动清空。 + 挂载条件包含"过渡态":已经走到 match_skills 但还没出 select_skills 时也保持挂载, + 让首个空 chunk 一到就能立刻显示等待提示 + 闪烁光标,避免模型 buffer 期间静默。 */} + {state.status === 'researching' && ( + state.skillSelectStreamingText + || state.skillSelectThinkingText + || (state.skillSearchSteps.some((s) => s.type === 'match_skills') + && !state.skillSearchSteps.some((s) => s.type === 'select_skills')) + ) && ( 0) && - /* 技能检索已经在流式展示步骤 / 选择打字机,不需要再叠加 skeleton */ - !(state.status === 'researching' && (state.skillSearchSteps.length > 0 || state.skillSelectStreamingText || state.skillSelectThinkingText)) && + /* 技能检索已经在流式展示步骤 / 选择打字机 / 等待 chunk 中,不再叠加 skeleton */ + !(state.status === 'researching' && state.skillSearchSteps.length > 0) && + !(state.status === 'researching' && (state.skillSelectStreamingText || state.skillSelectThinkingText)) && /* 意图解析流式过程中(LLM 还在吐 JSON)不要显示 skeleton */ !state.intentStreamingText && !state.intentThinkingText && (
diff --git a/frontend-web/src/open-canvas/components/ai-writing/SkillSelectStreamingView.tsx b/frontend-web/src/open-canvas/components/ai-writing/SkillSelectStreamingView.tsx index ca8bd908..f1c958c0 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/SkillSelectStreamingView.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/SkillSelectStreamingView.tsx @@ -7,25 +7,22 @@ interface SkillSelectStreamingViewProps { } /** - * 从流式 JSON 文本里增量提取已经识别出的技能名。 + * 从流式 JSON 文本里增量提取已识别的技能名(仅取**已闭合**的字符串项)。 * - * LLM 输出形如 `{"selected_skills": ["skill-a", "skill-b", "skill-c"], "reason": "..."}`, - * 我们不等整段 JSON 解析完,逐字符扫描"已闭合"的双引号字符串: - * - 找到 `"selected_skills"`(或末尾不完整时回退兜底)后取后续内容 - * - 在该位置之后的 `[ ... ]` 内逐个匹配 `"..."` - * - 只取**已闭合**的字符串(结尾必须是 `"`),确保不把正在打字的半截名字露出来 + * LLM 输出形如 `{"selected_skills": ["skill-a", "skill-b", "skill-c"], "reason": "..."}`。 + * 不等整段 JSON 解析完,逐字符扫描: + * - 定位 `"selected_skills":\s*[` + * - 在该位置后到 `]`(或文本末尾)之间逐个匹配 `"..."` + * - 只取已闭合的字符串(结尾必须是 `"`),保证不显示正在打字的半截名字 */ function extractStreamingSkillNames(text: string): string[] { if (!text) return []; - // 定位 selected_skills 的值起点(容错:允许中英文引号 / 不同空白) const keyMatch = text.match(/["']?selected_skills["']?\s*:\s*\[/); if (!keyMatch) return []; const start = keyMatch.index! + keyMatch[0].length; - // 截取数组段(找到对应的 ],或者文本结尾) const rest = text.slice(start); const endIdx = rest.indexOf(']'); const arrBody = endIdx >= 0 ? rest.slice(0, endIdx) : rest; - // 只提取已经"闭合"的字符串:[^"\\]*(?:\\.[^"\\]*)* 处理转义引号 const names: string[] = []; const re = /"((?:[^"\\]|\\.)*)"/g; let m: RegExpExecArray | null; @@ -37,32 +34,46 @@ function extractStreamingSkillNames(text: string): string[] { } /** - * 技能选择 LLM 流式打字机视图。 + * 从流式 JSON 文本里提取 `reason` 字段当前已输出的内容。 * - * - 思考过程( 内)用 amber 折叠面板(与 OutlineStreamingView 风格一致) - * - 正文 JSON 用 violet 主题(与 SkillStepsCard 同色系,强调"技能"语义) - * - 两边都自动滚动到底;正文带闪烁光标,思考过程在正文未开始时也有光标 + * 和 skill name 不同,reason 是流式叙述文本,**未闭合也要展示**(带闪烁光标), + * 让用户看到 LLM 正在解释选择理由。 */ +function extractStreamingReason(text: string): { value: string; closed: boolean } { + if (!text) return { value: '', closed: false }; + // 匹配 "reason": "..." 直到下一个未转义的 " 或文本末尾 + const m = text.match(/["']?reason["']?\s*:\s*"((?:[^"\\]|\\.)*?)("|$)/); + if (!m) return { value: '', closed: false }; + const raw = m[1]; + const closed = m[2] === '"'; + // 反转义常见序列 + const value = raw + .replace(/\\n/g, '\n') + .replace(/\\t/g, '\t') + .replace(/\\"/g, '"') + .replace(/\\\\/g, '\\'); + return { value, closed }; +} + export function SkillSelectStreamingView({ text, thinkingText }: SkillSelectStreamingViewProps) { - const preRef = useRef(null); const thinkRef = useRef(null); const [thinkOpen, setThinkOpen] = useState(true); - // 实时从流式 JSON 中增量解析出已识别的技能名 + // 增量解析:候选技能 + 选择原因 const streamingSkills = useMemo(() => extractStreamingSkillNames(text), [text]); - - useEffect(() => { - if (preRef.current) preRef.current.scrollTop = preRef.current.scrollHeight; - }, [text]); + const reason = useMemo(() => extractStreamingReason(text), [text]); useEffect(() => { if (thinkOpen && thinkRef.current) thinkRef.current.scrollTop = thinkRef.current.scrollHeight; }, [thinkingText, thinkOpen]); - if (!text && !thinkingText) return null; + // 步骤预览始终渲染(挂载即显示),让父组件控制挂载时机; + // 这样首个空 chunk 到达时用户就能看到"等待模型输出"占位 + 跳动光标 + const showStepPreview = true; return (
+ {/* 思考过程(amber,可折叠) */} {thinkingText && (
+ ); + } + return (
))} + + {/* 底部"收起面板"按钮:独立样式,mt-auto 推到底 */} +
); diff --git a/frontend-web/src/core/auth/external-login.ts b/frontend-web/src/core/auth/external-login.ts new file mode 100644 index 00000000..219db2b2 --- /dev/null +++ b/frontend-web/src/core/auth/external-login.ts @@ -0,0 +1,57 @@ +/** + * 外部系统跳转登录时透传的参数: + * theme —— 主题色,"dark-blue" => 深色,其他 => 浅色(在 LoginPage 调 setTheme 直接生效) + * ticket —— 第三方 ticket,持久化到 localStorage 供后续请求使用 + * userId —— 外部用户 id,同上 + * env —— 环境标识,同上 + * + * 这里只暴露 ``ticket / userId / env`` 三个 storage key 和读写工具; + * theme 不存(直接走 ThemeContext,避免双源)。 + */ + +export const EXTERNAL_LOGIN_STORAGE_KEYS = { + ticket: "login.ticket", + userId: "login.userId", + env: "login.env", +} as const; + +export type ExternalLoginParams = { + ticket?: string; + userId?: string; + env?: string; +}; + +/** 把外部跳转带来的三个参数写到 localStorage;隐私模式 / quota 异常时静默失败 */ +export function persistExternalLoginParams(params: ExternalLoginParams): void { + try { + if (params.ticket) { + localStorage.setItem(EXTERNAL_LOGIN_STORAGE_KEYS.ticket, params.ticket); + } + if (params.userId) { + localStorage.setItem(EXTERNAL_LOGIN_STORAGE_KEYS.userId, params.userId); + } + if (params.env) { + localStorage.setItem(EXTERNAL_LOGIN_STORAGE_KEYS.env, params.env); + } + } catch { + // localStorage 不可用时不阻断登录流程 + } +} + +/** 读取持久化的外部登录参数;不可用时返回空字符串 */ +export function readExternalLoginParams(): Required { + try { + return { + ticket: localStorage.getItem(EXTERNAL_LOGIN_STORAGE_KEYS.ticket) ?? "", + userId: localStorage.getItem(EXTERNAL_LOGIN_STORAGE_KEYS.userId) ?? "", + env: localStorage.getItem(EXTERNAL_LOGIN_STORAGE_KEYS.env) ?? "", + }; + } catch { + return { ticket: "", userId: "", env: "" }; + } +} + +/** 把 ``theme`` query 值映射成 ThemeContext 的 ``'dark' | 'light'`` */ +export function resolveThemeFromQuery(themeParam: string | null | undefined): "dark" | "light" { + return themeParam === "dark-blue" ? "dark" : "light"; +} diff --git a/frontend-web/src/core/page-layout/sidebar-menu.ts b/frontend-web/src/core/page-layout/sidebar-menu.ts index 446d6be8..d2fe690a 100644 --- a/frontend-web/src/core/page-layout/sidebar-menu.ts +++ b/frontend-web/src/core/page-layout/sidebar-menu.ts @@ -68,40 +68,40 @@ export const sidebarMenuItems: MenuItem[] = [ { id: "qa-research", label: "课题研究", icon: FileText, path: "/page/canvas/ai-writing" }, ], }, - { - id: "light-app-center", - label: "轻应用", - icon: Grid3X3, - children: [ - { - id: "light-app-note-management", - label: "笔记管理", - icon: NotebookPen, - children: [ - { - id: "light-app-new-note", - label: "新建笔记", - icon: Plus, - path: "/page/strategy/light-app/new-note", - }, - { - id: "light-app-note-list", - label: "笔记列表", - icon: List, - path: "/page/strategy/light-app/notes", - }, - { - id: "light-app-note-qa", - label: "笔记问答", - icon: MessageSquareCode, - path: "/page/strategy/light-app/note-qa", - }, - ], - }, - { id: "light-app-square", label: "应用广场", icon: Layout }, - { id: "light-app-records", label: "运行记录", icon: History }, - ], - }, + // { + // id: "light-app-center", + // label: "轻应用", + // icon: Grid3X3, + // children: [ + // { + // id: "light-app-note-management", + // label: "笔记管理", + // icon: NotebookPen, + // children: [ + // { + // id: "light-app-new-note", + // label: "新建笔记", + // icon: Plus, + // path: "/page/strategy/light-app/new-note", + // }, + // { + // id: "light-app-note-list", + // label: "笔记列表", + // icon: List, + // path: "/page/strategy/light-app/notes", + // }, + // { + // id: "light-app-note-qa", + // label: "笔记问答", + // icon: MessageSquareCode, + // path: "/page/strategy/light-app/note-qa", + // }, + // ], + // }, + // { id: "light-app-square", label: "应用广场", icon: Layout }, + // { id: "light-app-records", label: "运行记录", icon: History }, + // ], + // }, { id: "task-management", label: "任务管理", diff --git a/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx b/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx index b319a573..87e75736 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx @@ -463,8 +463,9 @@ export function AIWritingPanel({ /> )} - {/* 研究中 + 已有技能步骤:实时流式展示技能检索过程,避免 skeleton 卡片掩盖步骤 */} - {state.status === 'researching' && state.skillSearchSteps.length > 0 && ( + {/* 技能检索 pending:用户点击瞬间到 awaiting 之间,始终显示 SkillStepsCard 占位 + steps.length === 0 时 SkillStepsCard 内部会渲染骨架行,不让用户干等空白 */} + {state.skillSearchPending && (
@@ -474,8 +475,8 @@ export function AIWritingPanel({ state.status !== 'writing_draft' && !(state.status === 'writing_outline' && (state.outlineStreamingText || state.outlineThinkingText)) && !(state.status === 'reviewing' && state.streamingReviewItems.length > 0) && - /* 技能检索已经在流式展示步骤 / 选择打字机 / 等待 chunk 中,不再叠加 skeleton */ - !(state.status === 'researching' && state.skillSearchSteps.length > 0) && + /* 技能检索占位卡 / 选择打字机 已经在流式展示,不再叠加 skeleton */ + !state.skillSearchPending && !(state.status === 'researching' && (state.skillSelectStreamingText || state.skillSelectThinkingText)) && /* 意图解析流式过程中(LLM 还在吐 JSON)不要显示 skeleton */ !state.intentStreamingText && !state.intentThinkingText && ( diff --git a/frontend-web/src/open-canvas/components/ai-writing/MaterialsToolbar.tsx b/frontend-web/src/open-canvas/components/ai-writing/MaterialsToolbar.tsx index 1b149ed4..66906405 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/MaterialsToolbar.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/MaterialsToolbar.tsx @@ -73,7 +73,7 @@ export function MaterialsToolbar({ value={userQuery} onChange={handleInput} onKeyDown={handleKeyDown} - placeholder={placeholder ?? '例如:想查看各大平台销量 / 2025 年财报 / 行业头部公司近期动态…'} + placeholder={placeholder ?? '输入文字后点击“重新搜索”或“技能检索”按钮…'} disabled={disabled} className="w-full resize-none rounded-md border border-input bg-background px-2.5 py-1.5 text-xs leading-5 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50 min-h-[28px] max-h-24" /> diff --git a/frontend-web/src/open-canvas/components/ai-writing/SkillStepsCard.tsx b/frontend-web/src/open-canvas/components/ai-writing/SkillStepsCard.tsx index 2f95f1fe..a243e295 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/SkillStepsCard.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/SkillStepsCard.tsx @@ -118,9 +118,28 @@ export function SkillStepsCard({ steps, inProgress }: SkillStepsCardProps) { ); })} - {inProgress && ( + {inProgress && steps.length === 0 && ( + // 用户刚点击"技能检索",还没收到任何步骤事件:用骨架行 + spinner 占位, + // 避免用户在等待后端 emit 时干等空白 + <> +
  • + + 正在准备技能检索… +
  • + {[0, 1, 2].map((i) => ( +
  • + +
    +
    +
    +
    +
  • + ))} + + )} + {inProgress && steps.length > 0 && (
  • - + 更多步骤生成中…
  • )} diff --git a/frontend-web/src/open-canvas/hooks/useAIWritingStream.ts b/frontend-web/src/open-canvas/hooks/useAIWritingStream.ts index 0cd2943e..94b5ada4 100644 --- a/frontend-web/src/open-canvas/hooks/useAIWritingStream.ts +++ b/frontend-web/src/open-canvas/hooks/useAIWritingStream.ts @@ -75,6 +75,7 @@ const INITIAL_STATE: AIWritingState = { skillSearchSteps: [], skillSelectStreamingText: '', skillSelectThinkingText: '', + skillSearchPending: false, streamingReviewItems: [], revisionSummary: null, pendingRevisionContext: null, @@ -105,10 +106,13 @@ function reducer(prev: AIWritingState, action: Action): AIWritingState { case 'intervention_submitted': { const ns = action.nextStatus; + // 点击瞬间标记技能检索 pending(动机 = 用户的 action 类型),让 SkillStepsCard 占位卡立即挂载 + const enablingSkillSearch = action.record.action === 'enable_skill_search'; return { ...prev, currentPause: null, status: ns, + skillSearchPending: enablingSkillSearch ? true : prev.skillSearchPending, ...(ns === 'researching' ? { researchProgress: '', skillSearchSteps: [], skillSelectStreamingText: '', skillSelectThinkingText: '' } : {}), ...(ns === 'writing_outline' ? { outlineStreamingText: '', outlineThinkingText: '' } : {}), ...(ns === 'writing_draft' ? { streamingDraftSections: [], streamingDraftTitle: '' } : {}), @@ -341,7 +345,9 @@ function handleEvent(prev: AIWritingState, event: ProgressEvent): AIWritingState const extra: Partial = {}; if (pp === 'material_confirm' && event.materials?.length) extra.materials = event.materials; if (pp === 'outline_confirm' && event.outline) extra.outline = event.outline; - return { ...base, ...extra, status: pauseStatus, currentPause: event }; + // 进入素材确认(material_confirm)= 技能检索(如果在进行)已经完成,清掉 pending 占位 + const clearPending = pp === 'material_confirm' ? { skillSearchPending: false } : {}; + return { ...base, ...extra, ...clearPending, status: pauseStatus, currentPause: event }; } case 'done': diff --git a/frontend-web/src/open-canvas/types/ai-writing.ts b/frontend-web/src/open-canvas/types/ai-writing.ts index faa6a3c6..98d6a380 100644 --- a/frontend-web/src/open-canvas/types/ai-writing.ts +++ b/frontend-web/src/open-canvas/types/ai-writing.ts @@ -321,6 +321,9 @@ export interface AIWritingState { // 技能选择 LLM 流式打字内容(正文 / 思考分开),select_skills 完成后清空 skillSelectStreamingText: string; skillSelectThinkingText: string; + /** 技能检索是否正在进行:用户点击"技能检索"瞬间 true,进入 awaiting 时清掉。 + * 用于在 LangGraph 还没推任何 skill_search_step 事件时给前端展示占位卡。 */ + skillSearchPending: boolean; // 流式审核(逐条到达) streamingReviewItems: StreamingReviewItem[]; // 修改摘要 diff --git a/frontend-web/src/pages/LoginPage.tsx b/frontend-web/src/pages/LoginPage.tsx index 16ee95b9..5edbf2db 100644 --- a/frontend-web/src/pages/LoginPage.tsx +++ b/frontend-web/src/pages/LoginPage.tsx @@ -3,8 +3,13 @@ import { useParams, useRouter, useSearchParams } from "next/navigation"; import { useEffect, useMemo, useState } from "react"; +import { useTheme } from "@/contexts/ThemeContext"; import { loginByUsername } from "@/core/auth/api"; import { setStoredAuth } from "@/core/auth"; +import { + persistExternalLoginParams, + resolveThemeFromQuery, +} from "@/core/auth/external-login"; import { isSidebarEnabledFromSearch, persistSidebarEnabled, @@ -18,6 +23,7 @@ export default function LoginPage() { const { username } = useParams<{ username?: string }>(); const router = useRouter(); const searchParams = useSearchParams(); + const { setTheme } = useTheme(); const sidebarEnabled = useMemo( () => isSidebarEnabledFromSearch(searchParams.toString()), [searchParams], @@ -26,9 +32,31 @@ export default function LoginPage() { const raw = username?.trim(); return raw && raw.length > 0 ? raw : DEFAULT_USERNAME; }, [username]); + // 外部跳转参数:theme 直接调 setTheme;ticket/userId/env 持久化到 localStorage + const externalParams = useMemo(() => ({ + theme: searchParams.get("theme") ?? "", + ticket: searchParams.get("ticket") ?? "", + userId: searchParams.get("userId") ?? "", + env: searchParams.get("env") ?? "", + }), [searchParams]); const [status, setStatus] = useState("loading"); const [errorMessage, setErrorMessage] = useState(""); + // 主题切换:theme=dark-blue → 深色,其他值或缺省 → 浅色 + useEffect(() => { + if (!externalParams.theme) return; + setTheme(resolveThemeFromQuery(externalParams.theme)); + }, [externalParams.theme, setTheme]); + + // ticket / userId / env 持久化(与 login 请求并行做,互不阻塞) + useEffect(() => { + persistExternalLoginParams({ + ticket: externalParams.ticket, + userId: externalParams.userId, + env: externalParams.env, + }); + }, [externalParams.ticket, externalParams.userId, externalParams.env]); + useEffect(() => { let cancelled = false; diff --git a/frontend-web/src/pages/PageRoutes.tsx b/frontend-web/src/pages/PageRoutes.tsx index e88e6562..db4ed060 100644 --- a/frontend-web/src/pages/PageRoutes.tsx +++ b/frontend-web/src/pages/PageRoutes.tsx @@ -24,7 +24,10 @@ function PageLayout() {
    {sidebarEnabled && ( isDark ? ( - + setCollapsed((prev) => !prev)} + /> ) : ( diff --git a/frontend-web/src/pages/WorkspaceRoutes.tsx b/frontend-web/src/pages/WorkspaceRoutes.tsx index 08b7181d..f70d42a1 100644 --- a/frontend-web/src/pages/WorkspaceRoutes.tsx +++ b/frontend-web/src/pages/WorkspaceRoutes.tsx @@ -59,10 +59,12 @@ export function WorkspaceLayout({ const pageSidebarWidth = pageSidebarHidden ? "0rem" : pageSidebarDark - // dark page sidebar 视觉右边界是 108px(外框 98 + 内层 translateX 10), - // 再 +10px gap 让 workspace sidebar 跟它之间留一条间隔; - // 与 PageRoutes.tsx 的 main pl-[118px] 保持一致 - ? "118px" + // dark page sidebar: + // 展开: 视觉右边界 108px(外框 98 + 内层 translateX 10)+ 10px gap = 118px + // 收起: sidebar 主体完全消失(连同其占据的宽度),workspace 内容铺到屏幕左边; + // 左下角浮动按钮是 fixed,叠在主内容上不影响 layout + // 与 PageRoutes.tsx 的 main pl-[118px] / pl-0 保持一致 + ? (pageSidebarCollapsed ? "0rem" : "118px") : pageSidebarCollapsed ? "4rem" : "16rem"; From 8e931c5acc2de394510c086895017809d0bc4e1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Fri, 29 May 2026 20:56:07 +0800 Subject: [PATCH 04/28] =?UTF-8?q?=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- offline-backend-20260512/backend/config.yaml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/offline-backend-20260512/backend/config.yaml b/offline-backend-20260512/backend/config.yaml index 5bda2872..5ea07717 100644 --- a/offline-backend-20260512/backend/config.yaml +++ b/offline-backend-20260512/backend/config.yaml @@ -23,6 +23,18 @@ models: supports_vision: true supports_thinking: true + - name: deepseek-chat + display_name: DeepSeek Chat + use: langchain_openai:ChatOpenAI + model: deepseek-chat + api_key: sk-1520028668cb4cc388ed8315ff1e856e + base_url: https://api.deepseek.com + request_timeout: 600.0 + max_retries: 2 + max_tokens: 4096 + temperature: 1.0 + supports_vision: true + supports_thinking: true - name: deepseek-v4-flash display_name: DeepSeek V4 Flash use: langchain_openai:ChatOpenAI From 24e92c437d5f64277ecdb85d539d24acd8262034 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Fri, 29 May 2026 22:16:19 +0800 Subject: [PATCH 05/28] =?UTF-8?q?ai=E5=86=99=E4=BD=9Cbug=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- offline-backend-20260512/backend/config.yaml | 12 +++++- .../agents/ai_writing/nodes/_utils.py | 37 +++++++++++++++++++ .../agents/ai_writing/nodes/editor.py | 8 +++- .../agents/ai_writing/nodes/intent_parser.py | 11 ++++++ .../agents/ai_writing/nodes/writer_draft.py | 15 +++++++- .../agents/ai_writing/nodes/writer_outline.py | 10 +++-- .../agents/ai_writing/search/skill_search.py | 5 +++ .../deerflow/agents/ai_writing/state.py | 5 +++ 8 files changed, 95 insertions(+), 8 deletions(-) diff --git a/offline-backend-20260512/backend/config.yaml b/offline-backend-20260512/backend/config.yaml index 9a27a6ce..ace92056 100644 --- a/offline-backend-20260512/backend/config.yaml +++ b/offline-backend-20260512/backend/config.yaml @@ -14,7 +14,7 @@ models: display_name: zai-org/GLM-5-FP8 use: langchain_openai:ChatOpenAI model: zai-org/GLM-5-FP8 - api_key: Bearer modalresearch_gWu46FDpVLYZWPVEA3b_2fq8Jw2UJiKMgEm_Rxmn1SA + api_key: modalresearch_gWu46FDpVLYZWPVEA3b_2fq8Jw2UJiKMgEm_Rxmn1SA base_url: https://api.us-west-2.modal.direct/v1 request_timeout: 600.0 max_retries: 2 @@ -71,6 +71,16 @@ models: temperature: 1.0 supports_vision: true supports_thinking: true + - name: glm-5.1 + display_name: glm-5.1 + use: langchain_openai:ChatOpenAI + model: glm-5.1 + api_key: 75121e4bed7640d5b71ac95cf8b4310a.RA3GyRBAR3kv7VrR + base_url: https://open.bigmodel.cn/api/paas/v4/ + max_tokens: 4096 + temperature: 1.0 + supports_vision: true + supports_thinking: true title: enabled: true diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/_utils.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/_utils.py index 4a6ff0fa..3f34cf90 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/_utils.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/_utils.py @@ -74,6 +74,28 @@ def get_model(config: RunnableConfig, model_name: str | None = None): return create_chat_model(name=name) +def get_writing_model(config: RunnableConfig, state: dict): + """大纲 / 写作 / 审核专用:严格使用用户在表单选定的模型,**不回退默认**。 + + 用户模型在 intent_parser(首次 invocation,configurable.model_name 可用)阶段 + 捕获进 ``state['model_name']``,经 checkpoint 持久化。这三个节点都在 + 「确认素材 / 大纲」resume 之后才运行,而 resume 是新 invocation、其 config 不再 + 带 model_name —— 所以必须从 state 读,不能从 configurable 读(否则回退到 config + 列表第一个模型,常是推理模型,既影响质量也会让 reasoning_content 走不出流式)。 + + 缺失视为配置错误直接抛 ValueError —— 由各节点的 try/except 兜底成 status:error, + 而不是静默回退默认模型。 + """ + name = ((state or {}).get("model_name") or "").strip() + if not name: + # 兜底再看一眼 configurable(首次同 invocation 内直跑到这里的极端情况) + name = (config.get("configurable", {}).get("model_name", "") or "").strip() + if not name: + raise ValueError("未指定写作模型(model_name 为空):请在写作表单选择模型后重试") + from deerflow.models import create_chat_model + return create_chat_model(name=name) + + # 弱模型常把结构性标点写成中文全角符号,仅在常规解析失败后作为修复手段 _FULLWIDTH_FIX = { "“": '"', # 左双引号 “ @@ -175,6 +197,21 @@ def parse_json(text: str, fallback: dict) -> dict: return fallback +def reasoning_delta(chunk) -> str: + """提取流式 chunk 的增量「推理内容」。 + + 推理模型(deepseek-reasoner / GLM 等)会把思考过程放在 + ``additional_kwargs.reasoning_content`` 增量字段里,而 ``content`` 为空 —— + 只读 content 的流式循环会把这些 chunk 全部跳过,前端就看不到任何实时输出。 + 各写作节点用本函数把推理增量当作 ``is_thinking=True`` 推给前端的思考框。 + + 兼容不同 provider 的字段名:``reasoning_content``(主流)/ ``reasoning``。 + """ + ak = getattr(chunk, "additional_kwargs", None) or {} + val = ak.get("reasoning_content") or ak.get("reasoning") or "" + return val if isinstance(val, str) else "" + + def _content_to_text(content) -> str: """将 LLM 响应内容统一为字符串(部分模型返回结构化 content 块列表)。""" if isinstance(content, str): diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/editor.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/editor.py index 049bc178..3b0c9e5d 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/editor.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/editor.py @@ -11,7 +11,7 @@ from ..state import AIWritingState, ReviewResult, ReviewIssue from ..prompts.editor_prompts import ( EDITOR_SYSTEM, FACT_REVIEW_USER, LOGIC_REVIEW_USER, LANGUAGE_REVIEW_USER, ) -from ._utils import get_ai_writing_config, get_model, parse_json, _content_to_text, ThinkTagParser, safe_get_stream_writer +from ._utils import get_ai_writing_config, get_writing_model, parse_json, _content_to_text, reasoning_delta, ThinkTagParser, safe_get_stream_writer logger = logging.getLogger(__name__) @@ -49,6 +49,10 @@ async def _stream_one_review( SystemMessage(content=EDITOR_SYSTEM), HumanMessage(content=user_prompt), ]): + # 推理模型的思考走 reasoning_content(content 为空):作为思考实时推送 + reasoning = reasoning_delta(chunk) + if reasoning: + writer({"type": "review_chunk", "category": category, "text": reasoning, "is_thinking": True}) delta = _content_to_text(chunk.content) if not delta: continue @@ -118,7 +122,7 @@ async def editor_node(state: AIWritingState, config: RunnableConfig) -> dict: # 模型创建守卫:模型名配错时 create_chat_model 抛 ValueError, # 不拦截会硬崩溃整个图;此处转成可恢复的 status:error。 try: - model = get_model(config) + model = get_writing_model(config, state) except Exception as e: logger.error("editor model creation failed: %s", e, exc_info=True) return { diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/intent_parser.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/intent_parser.py index d5008461..ac94bc91 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/intent_parser.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/intent_parser.py @@ -14,6 +14,7 @@ from ._utils import ( _content_to_text, get_model, parse_json, + reasoning_delta, safe_get_stream_writer, ) @@ -215,6 +216,10 @@ async def intent_parser_node(state: AIWritingState, config: RunnableConfig) -> d async for chunk in model.astream( [SystemMessage(content=system), HumanMessage(content=user)] ): + # 推理模型的思考走 reasoning_content(content 为空):作为思考实时推送 + reasoning = reasoning_delta(chunk) + if reasoning: + writer({"type": "intent_chunk", "text": reasoning, "is_thinking": True}) delta = _content_to_text(chunk.content) if not delta: continue @@ -259,6 +264,12 @@ async def intent_parser_node(state: AIWritingState, config: RunnableConfig) -> d progress_events.append({"type": "step_done", "agent_name": "系统", "message": "意图解析完成"}) updates: dict = {"status": "researching", "progress_events": progress_events} + # 捕获用户表单选定的主模型进 state(仅本节点能可靠拿到 configurable.model_name — + # 它运行在首次 invocation;后续大纲/写作/审核在 resume 后跑,config 已不带它)。 + # 大纲/写作/审核经 get_writing_model 严格读 state['model_name'],不回退默认。 + selected_model = (configurable.get("model_name") or "").strip() + if selected_model: + updates["model_name"] = selected_model updates["article_type"] = final_article_type updates["target_audience"] = final_target_audience updates["word_count_target"] = final_word_count diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/writer_draft.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/writer_draft.py index 5578a6f5..9313bdb0 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/writer_draft.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/writer_draft.py @@ -12,7 +12,7 @@ from ..prompts.writer_prompts import ( DRAFT_SECTION_SYSTEM, DRAFT_SECTION_USER, DRAFT_EXPAND_USER, WRITER_MODE_LOOSE, WRITER_MODE_STRICT, SECTION_BLOCKED_MARKER, ) -from ._utils import get_model, _content_to_text, count_words, ThinkTagParser, safe_get_stream_writer +from ._utils import get_writing_model, _content_to_text, count_words, reasoning_delta, ThinkTagParser, safe_get_stream_writer logger = logging.getLogger(__name__) @@ -197,7 +197,7 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di # 模型创建守卫:模型名配错时 create_chat_model 抛 ValueError, # 不拦截会硬崩溃整个图;此处转成可恢复的 status:error。 try: - model = get_model(config) + model = get_writing_model(config, state) except Exception as e: logger.error("draft model creation failed: %s", e, exc_info=True) return { @@ -250,6 +250,17 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di SystemMessage(content=DRAFT_SECTION_SYSTEM), HumanMessage(content=user_msg), ]): + # 推理模型的思考走 reasoning_content(content 为空):作为思考实时推送 + reasoning = reasoning_delta(chunk) + if reasoning: + writer({ + "type": "draft_chunk", + "section_index": i, + "section_title": sec_title, + "text": reasoning, + "is_thinking": True, + "is_section_done": False, + }) delta = _content_to_text(chunk.content) if not delta: continue diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/writer_outline.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/writer_outline.py index cf8c01df..566011b2 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/writer_outline.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/writer_outline.py @@ -11,7 +11,7 @@ from ..state import AIWritingState, KeyPoint, Outline, OutlineSection from ..prompts.writer_prompts import ( OUTLINE_SYSTEM, OUTLINE_USER, OUTLINE_REVISE_SYSTEM, OUTLINE_REVISE_USER, ) -from ._utils import get_model, parse_json, _content_to_text, ThinkTagParser, safe_get_stream_writer +from ._utils import get_writing_model, parse_json, _content_to_text, reasoning_delta, ThinkTagParser, safe_get_stream_writer logger = logging.getLogger(__name__) @@ -125,13 +125,17 @@ async def writer_outline_node(state: AIWritingState, config: RunnableConfig) -> parser = ThinkTagParser() try: - # get_model 放进 try:模型名配错时 create_chat_model 抛 ValueError, + # get_writing_model 放进 try:用户模型缺失 / 模型名配错时抛 ValueError, # 留在 try 外会硬崩溃;放进来则统一走下方的 status:error 兜底。 - model = get_model(config) + model = get_writing_model(config, state) async for chunk in model.astream([ SystemMessage(content=system_prompt), HumanMessage(content=user_prompt), ]): + # 推理模型的思考走 reasoning_content(content 为空):作为思考实时推送 + reasoning = reasoning_delta(chunk) + if reasoning: + writer({"type": "outline_chunk", "text": reasoning, "is_thinking": True}) delta = _content_to_text(chunk.content) if not delta: continue diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/search/skill_search.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/search/skill_search.py index 1b4ea6e0..d28e79c7 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/search/skill_search.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/search/skill_search.py @@ -26,6 +26,7 @@ from ..nodes._utils import ( _content_to_text, get_model, parse_json, + reasoning_delta, safe_get_stream_writer, ) @@ -163,6 +164,10 @@ class SkillPicker: max_select=self._max_select, )), ]): + # 推理模型的思考走 reasoning_content(content 为空):作为思考实时推送 + reasoning = reasoning_delta(chunk) + if reasoning: + _emit_chunk(reasoning, True) delta = _content_to_text(chunk.content) if not delta: continue diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/state.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/state.py index 2420ef50..90074818 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/state.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/state.py @@ -132,6 +132,11 @@ class AIWritingState(TypedDict): keyword_count: int # 写作模式:strict=严格依据素材 / loose=可结合通用知识(默认 loose) writing_mode: str + # 用户在写作表单选定的主模型。大纲/写作/审核严格使用它(不回退默认)。 + # 由 intent_parser 在首次 invocation 从 configurable.model_name 捕获进 state, + # 经 checkpoint 持久化 —— interrupt/resume 是新 invocation、其 config 不带原 + # model_name,存进 state 才能让 resume 后才运行的节点拿到用户模型。 + model_name: str # Agent A 输出 material_package: Optional[MaterialPackage] From 838cae0d2dccb5e113aa5e95c2c28637b3eb9eaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Sun, 31 May 2026 00:23:25 +0800 Subject: [PATCH 06/28] =?UTF-8?q?=E5=9C=86=E6=A1=8C=E8=8D=89=E7=A8=BF?= =?UTF-8?q?=E6=8C=81=E4=B9=85=E5=8C=96=EF=BC=9AlocalStorage=20=E8=BF=81?= =?UTF-8?q?=E8=87=B3=E5=90=8E=E7=AB=AF=20MySQL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 /api/roundtable-drafts 草稿 CRUD 与会话级推荐历史;前端 useDraftPersistence 改走 api/drafts,删除本地 drafts.ts。 Co-authored-by: Cursor --- frontend-web/docs/multi-agent-backend-dev.md | 67 ++- frontend-web/docs/multi-agent-frontend-dev.md | 66 ++- .../src/roundtable-planning/api/drafts.ts | 211 +++++++++ .../src/roundtable-planning/api/recommend.ts | 98 ++++ .../components/RecommendAgentsDialog.tsx | 419 +++++++++++------- .../hooks/useDraftPersistence.ts | 304 +++++++++---- .../pages/RoundtablePlanningPage.tsx | 5 +- .../src/roundtable-planning/utils/drafts.ts | 200 --------- offline-backend-20260512/backend/CLAUDE.md | 1 + .../backend/app/gateway/app.py | 4 + .../backend/app/gateway/deps.py | 4 + .../app/gateway/routers/roundtable_drafts.py | 223 ++++++++++ .../deerflow/persistence/models/__init__.py | 6 + .../persistence/roundtable_drafts/__init__.py | 22 + .../persistence/roundtable_drafts/model.py | 69 +++ .../persistence/roundtable_drafts/sql.py | 191 ++++++++ 16 files changed, 1410 insertions(+), 480 deletions(-) create mode 100644 frontend-web/src/roundtable-planning/api/drafts.ts delete mode 100644 frontend-web/src/roundtable-planning/utils/drafts.ts create mode 100644 offline-backend-20260512/backend/app/gateway/routers/roundtable_drafts.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_drafts/__init__.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_drafts/model.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_drafts/sql.py diff --git a/frontend-web/docs/multi-agent-backend-dev.md b/frontend-web/docs/multi-agent-backend-dev.md index 4492f3e5..3e5bf522 100644 --- a/frontend-web/docs/multi-agent-backend-dev.md +++ b/frontend-web/docs/multi-agent-backend-dev.md @@ -20,6 +20,7 @@ - **leader 共识 = `status: []` 空数组**;澄清 = `status: "clarification"`(字符串);派活 = `status: [[name, task], ...]`。这三者**互斥不混用**。 - **后端 picks clamp 2–6,前端用户可手动加到 8**。 - **不写死任何具体模型名**:`intent / recommend / multi_agent` 三个 router 的 `model_name = str(payload.get("model") or "")`,空串会被下游 `lead_agent._resolve_model_name()` 自动回退到 `config.yaml` 的 `models[0]`(与 `/api/ai-writing` 行为一致)。内网部署只需配 `config.yaml`,前后端均无需改硬编码。 +- **草稿/推荐历史持久化(2026-06)**:圆桌页的「历史草稿」从浏览器 `localStorage` 迁到**后端 MySQL**(`/api/roundtable-drafts`,按登录用户隔离);新增**会话级推荐历史**让推荐弹窗回显上次结果。两张表 `roundtable_drafts` / `roundtable_recommend_history`,启动 `create_all` 自动建表,详见 §7A。 --- @@ -73,7 +74,12 @@ offline-backend-20260512/backend/ │ ├── multi_agent.py # Step 2:init(SSE) + run/stream + 共享的 loopback 工具 │ ├── intent.py # Step 1:init + stream(import multi_agent 工具) │ ├── recommend.py # Step 1→2:推荐 stream(import multi_agent 工具) -│ └── artifacts.py # Step 3:列出/下载/编辑 thread 产出(通用,非圆桌专用) +│ ├── artifacts.py # Step 3:列出/下载/编辑 thread 产出(通用,非圆桌专用) +│ └── roundtable_drafts.py # 持久化:草稿 CRUD + 推荐历史(§7A,2026-06 新增) +├── packages/harness/deerflow/persistence/roundtable_drafts/ +│ ├── model.py # ORM:RoundtableDraftRow + RoundtableRecommendHistoryRow +│ ├── sql.py # RoundtableDraftRepository(CRUD + 历史) +│ └── __init__.py # make_roundtable_draft_store(sf) ├── packages/harness/deerflow/tools/builtins/ │ └── clarification_utils.py # resolve_allow_multiple + coerce_bool(Step1/Step2 共享) └── .deer-flow/agents/ @@ -110,8 +116,15 @@ offline-backend-20260512/backend/ | GET | `/api/threads/{thread_id}/artifacts` | 3 | 列出该 thread 的 outputs 文件 | | GET | `/api/threads/{thread_id}/artifacts/{path}` | 3 | 预览/下载单个产出(含 `.skill/` 内文件提取) | | PATCH | `/api/threads/{thread_id}/artifacts/{path}` | 3 | 编辑文本类产物(白名单后缀) | +| GET | `/api/roundtable-drafts` | 持久化 | 列出当前用户草稿(轻量元数据) | +| POST | `/api/roundtable-drafts` | 持久化 | 新建草稿 | +| GET | `/api/roundtable-drafts/{id}` | 持久化 | 取草稿全量(含 step1/step2 快照) | +| PUT | `/api/roundtable-drafts/{id}` | 持久化 | 局部更新(title / furthest_step / step1 / step2) | +| DELETE | `/api/roundtable-drafts/{id}` | 持久化 | 删除草稿 | +| GET | `/api/roundtable-drafts/{id}/recommendations` | 持久化 | 列出该草稿的推荐历史(倒序) | +| POST | `/api/roundtable-drafts/{id}/recommendations` | 持久化 | 追加一次推荐记录 | -字段级请求/响应见各 step 的 API 文档;下文只写**后端实现语义**。 +字段级请求/响应见各 step 的 API 文档;下文只写**后端实现语义**。圆桌草稿/推荐历史持久化见 §7A。 --- @@ -589,6 +602,53 @@ Step 3 **没有** 单独的 `multi_agent` 路由;使用既有 artifacts 能力 --- +## 7A. 圆桌草稿与推荐历史持久化(`roundtable_drafts.py`,2026-06) + +圆桌规划页的「历史记录」此前只存浏览器 `localStorage`(前端 `utils/drafts.ts`),换设备/清缓存即丢、大对话还会撞 5MB 配额。2026-06 迁到**后端 MySQL**,按登录用户隔离、跨设备保留;并新增**会话级推荐历史**,让推荐弹窗能回显上次推荐结果。 + +### 7A.1 数据模型(`deerflow.persistence.roundtable_drafts`) + +仿 `user_prompts`(最简 CRUD)+ `ai_writing_sessions`(大 JSON 用 `PortableLongText` 存)两个现有模式。两张表: + +- **`roundtable_drafts`**(会话主表,一条 = 一次完整圆桌会话) + - `id`(PK, str64) / `user_id`(idx) / `title`(512) / `furthest_step`(int, 1|2|3) + - `step1` / `step2`:前端 `DraftStep{1,2}Snapshot` 的 JSON,列类型 **`PortableLongText`**(MySQL `LONGTEXT`),避免 Step 2 长对话 + `seatStubMessages` 撞 `TEXT` 64KB 上限 + - `created_at` / `updated_at`:**`BeijingDateTime`**(项目规范,存北京时间且 tz-aware) +- **`roundtable_recommend_history`**(推荐历史,按 `draft_id` 归属) + - `id`(PK) / `user_id`(idx) / `draft_id`(idx) / `objective`(512) / `status`(32: done|fallback|asking|error) / `model`(128) + - `rationale` / `picks`(JSON) / `candidates`(JSON 候选池快照):均 `PortableLongText` + - `created_at`:`BeijingDateTime` + +JSON 列在 repo(`sql.py`)里用 `json.dumps/loads` 手动序列化(与 `ai_writing_sessions.transcript` 同法)。两个 Row 已登记进 `persistence/models/__init__.py`,**启动时 `Base.metadata.create_all` 自动建表**——新部署无需手动建表(需 mysql 账号有建库/表权限)。 + +### 7A.2 路由(`app/gateway/routers/roundtable_drafts.py`,前缀 `/api/roundtable-drafts`) + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `` | `list_drafts` → 轻量元数据列表(不含 step 大 blob),按 `updated_at` 倒序 | +| POST | `` | `create_draft`,可带客户端 `id`(稳定 id 跨 create+update) | +| GET | `/{id}` | `get_draft` 全量;非属主 → 404 | +| PUT | `/{id}` | `update_draft`,`model_dump(exclude_unset=True)` → 只更新客户端实际发的字段 | +| DELETE | `/{id}` | 204;非属主 → 404 | +| GET | `/{id}/recommendations` | 推荐历史倒序 | +| POST | `/{id}/recommendations` | 追加一次推荐(先校验草稿属主,404 拦截越权) | + +鉴权/属主:用 `_current_user_id(request)`(登录态取 `request.state.user.id`,无登录回退 `get_effective_user_id()` 的 `"default"`),与 `user_prompts` 一致;所有 repo 读写按 `user_id` 过滤,跨用户访问一律 404。store 在 `deps.py` 接为 `app.state.roundtable_draft_store`(`make_roundtable_draft_store(sf)`),`app.py` `include_router`。 + +### 7A.3 与前端的契约要点 + +- **PUT 不带 `title` 时不改标题**:前端自动保存只发 `furthest_step`/`step1`/`step2`,不发 `title`,因此**不会覆盖用户手动重命名**;标题只在 create(派生)与显式重命名(只发 title)时改。 +- **推荐时机**:前端在打开推荐弹窗前先 `ensureDraft` 落地草稿拿到 `draft_id`,推荐完成后 POST 一条历史;重开弹窗 GET 历史回显最近一次,可「重新分析」再 POST。 +- **字段命名**:后端 snake_case(`furthest_step`/`created_at`),前端 `api/drafts.ts` 在边界映射成 camelCase。 + +### 7A.4 设计取舍 + +- **整存 JSON 而非拆表**:step1/step2 形状宽且常变(前端 hook 里是 loose 类型),整存 JSON 避免每次改前端快照都要迁库;代价是后端不校验嵌套字段(与 `ai_writing_sessions.transcript` 同取舍)。 +- **推荐历史绑定草稿而非全局**:语义是「这个会话上次推荐了什么」,故按 `draft_id` 归属;这也要求推荐前必有 `draft_id`(前端 `ensureDraft` 保证)。 +- **本地 dev 走 sqlite**:`config.yaml` `database.backend` 本地默认 sqlite,走同一套 SQLAlchemy 代码,`PortableLongText/JSON` 各自降级;生产切 mysql 用同一份代码。 + +--- + ## 8. 内置席位 SOUL 设计要点 每个 `roundtable-*`(除 intent / recommender)遵循统一结构:角色定位、核心职责、输出风格(emoji + 简短结论)、边界(只产出本席位、等总控派活)。 @@ -1195,6 +1255,9 @@ client = httpx.AsyncClient(transport=transport, base_url=...) | 推荐路由 | `offline-backend-20260512/backend/app/gateway/routers/recommend.py` | | Step 2 路由 | `offline-backend-20260512/backend/app/gateway/routers/multi_agent.py` | | Step 3 列出/下载/编辑 | `offline-backend-20260512/backend/app/gateway/routers/artifacts.py` | +| 草稿/推荐历史路由(§7A) | `offline-backend-20260512/backend/app/gateway/routers/roundtable_drafts.py` | +| 草稿/推荐历史持久化 | `offline-backend-20260512/backend/packages/harness/deerflow/persistence/roundtable_drafts/` | +| store 接线 | `offline-backend-20260512/backend/app/gateway/deps.py`(`app.state.roundtable_draft_store`) | | 路由注册 | `offline-backend-20260512/backend/app/gateway/app.py` | | 澄清单选/多选 | `offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/clarification_utils.py` | | 任务理解 Agent | `offline-backend-20260512/backend/.deer-flow/agents/roundtable-intent/` | diff --git a/frontend-web/docs/multi-agent-frontend-dev.md b/frontend-web/docs/multi-agent-frontend-dev.md index 89ee2963..ea5235f7 100644 --- a/frontend-web/docs/multi-agent-frontend-dev.md +++ b/frontend-web/docs/multi-agent-frontend-dev.md @@ -14,6 +14,7 @@ - **步骤折叠卡与主聊天对齐**:思考过程 / 工具调用展示走 `MessageStepsCard`,**always-show-last-tool + 折叠 "查看其他 N 个步骤"** 的视觉与主聊天 `chats/new` 的 `MessageGroup` 一致。工具图标 / 文案映射在 `lib/step-display.tsx`,新增工具时与 `src/core/i18n/locales/zh-CN.ts` 的 `toolCalls` 字段对齐。 - **`` 标签自动提取为「思考过程」**:`lib/reasoning.ts::splitInlineReasoning(text)` 把 `...`(含未闭合的流式中段)抽离成 `reasoning`,正文走 markdown;由 `MessageBubble.tsx::ReasoningBlock` 渲染一个折叠卡。Step 1 / Step 2 气泡和推荐弹窗的 rationale 都走这一套,与主聊天 `Reasoning` 元素的视觉对齐。 - **不使用 LangGraph SDK**:所有 API 都是自研的 `apiFetch` + `ReadableStream` SSE 解析(见 `api/intent.ts`、`api/recommend.ts`、`api/multi-agent.ts`)。 +- **草稿/历史持久化在后端(2026-06)**:草稿不再存浏览器 `localStorage`,改走后端 MySQL(`/api/roundtable-drafts`,按登录用户隔离、跨设备保留)。`api/drafts.ts` 封装 CRUD,`useDraftPersistence` 异步对接、对外接口不变。**推荐弹窗带「会话级推荐历史」**:每个草稿一串历史,重开弹窗先显示上次推荐结果,可「重新分析」重跑(详见 §5.7 / §6.2)。后端实现见 backend doc 的「圆桌草稿持久化」一节。 - **不写死模型名**:与 `/page/canvas/ai-writing` 同样的策略 —— 全局选中模型由 `selectedModel` state 维护,首次 `useModels()` 完成后初始化为 `availableModels[0].name`,顶栏「模型」下拉可切换。所有 API 调用统一传 `model: selectedModel || undefined`;后端缺省由 `lead_agent._resolve_model_name()` 回退到 `config.yaml` 的 `models[0]`。**内网部署只需在 `config.yaml` 配自家模型即可,前后端均无需改动**。 - **席位区间硬约束**:推荐弹窗要求 **2 ≤ 选中数 ≤ 8**(`MIN_SELECTED` / `MAX_SELECTED`)。 - **协调智能体单例(2026-05)**:`COORDINATOR_AGENT_ID = "roundtable-coordinator"` 是固定常量,不再每会话生成新 id。后端 seeder 自动落地,「本次席位列表」改由后端通过 thread 历史 human 消息注入,前端无感。`newCoordinatorName()` 仅作为兼容性壳保留。 @@ -54,7 +55,8 @@ frontend-web/src/roundtable-planning/ │ └── RoundtablePlanningPage.tsx # 主页面:组合 hook + 顶栏/侧栏 + 三步路由(≈1 150 行) ├── api/ │ ├── intent.ts # Step 1:initIntent、streamIntent -│ ├── recommend.ts # 推荐:streamRecommend、filterRecommendCandidates、parseRecommendStreamContent +│ ├── recommend.ts # 推荐:streamRecommend、filterRecommendCandidates、parseRecommendStreamContent;**+ 推荐历史 listRecommendHistory / saveRecommendHistory** +│ ├── drafts.ts # **草稿后端 CRUD**:listDrafts / getDraft / createDraft / updateDraft / deleteDraft / deriveDraftName(替代旧 localStorage utils/drafts.ts) │ └── multi-agent.ts # Step 2/3:initMultiAgent、streamMultiAgent、listSessionArtifacts、buildArtifactDownloadUrl ├── components/ │ ├── Avatars.tsx # 所有 SVG 头像 + getAvatar 选择器 @@ -73,14 +75,13 @@ frontend-web/src/roundtable-planning/ │ ├── useAutoScroll.ts # 滚动跟随 + 用户上滑暂停跟随 │ ├── useStep1Intent.ts # Step 1 状态机:init、send、stream、abort、INTENT_READY 同步 │ ├── useStep2Orchestration.ts # Step 2 状态机:initMultiAgent + 编排循环 + 对话流管理 + Persona -│ └── useDraftPersistence.ts # 草稿:自动保存(前进切换)/手动保存/加载/重命名/删除 +│ └── useDraftPersistence.ts # 草稿(后端版):异步走 api/drafts;自动保存(前进切换)/手动保存/加载/重命名/删除/ensureDraft ├── lib/ │ ├── clarification.ts # resolveAllowMultiple(单选/多选解析),与主聊天 ClarificationCard 对齐 │ ├── reasoning.ts # splitInlineReasoning:从模型流式文本里提取 `...` 思考过程 │ ├── roundtable-constants.ts # 类型(RoleEntry / SelectedAgent) + 常量 + 纯工具函数(buildRoleTemplates / splitIntentReadyBlock 等) │ └── step-display.tsx # Step 2 ChainOfThought 折叠卡图标 + 文案 + 子节点渲染 -├── utils/ -│ └── drafts.ts # localStorage 草稿存档(list / upsert / get / delete / rename / deriveName) +├── utils/ # (旧 drafts.ts 已删除 —— 草稿改走后端 api/drafts.ts) └── styles/ └── roundtable-planning.css # 页面主题变量(浅红品牌色 + 深色覆盖) ``` @@ -430,19 +431,28 @@ isOrchestrating = false - **禁用态**:`availableModels.length === 0` 或 `modelsLoading` → `disabled`,placeholder 显示「加载中…」或「未配置模型」 - **可改换为 Radix Select**:当前用原生 ` setMaterialSource(v as 'knowledge_base' | 'skill')} + disabled={disabled} + > + + + + + {MATERIAL_SOURCE_OPTIONS.map((opt) => ( + + {opt.label} + + ))} + + +

    + 知识库:素材专家默认检索方式;技能检索:用已启用的技能去编排检索。 +

    +
    +
    setDraft(updateConfig(draft, "mode", value as SkillDisplayMode))} + value={editingConfig.mode} + onValueChange={(value) => updateEditingConfig("mode", value as SkillDisplayMode)} > @@ -300,8 +382,8 @@ export function SkillDisplayAdapterPanel({ skill }: { skill: Skill }) { + {activeResultSet && ( + + )} +
    + )} +
    {fieldLabels.map(([field, label]) => ( ))}
    - {draft.mode === "table" && ( + {editingConfig.mode === "table" && (