diff --git a/CLAUDE.md b/CLAUDE.md index c054b1d8..7dba5283 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,7 +78,8 @@ The **Lead Agent** (`deerflow/agents/lead_agent/`) is the single LangGraph graph The frontend (`frontend-web/`) is a React 19 + Vite SPA: - **Routing**: React Router v7; top-level routes in `src/pages/PageRoutes.tsx`, workspace sub-routes in `src/pages/WorkspaceRoutes.tsx` -- **Pages**: `ChatPage` (thread chat), `AgentChatPage` (custom agent chat), `AgentsPage`/`NewAgentPage` (agent management), `ScheduledTasksPage`/`ScheduledTaskRunDetailPage` (scheduled tasks) +- **Pages**: `ChatPage` (thread chat), `AgentChatPage` (custom agent chat), `AgentsPage`/`NewAgentPage` (agent management), `ScheduledTasksPage`/`ScheduledTaskRunDetailPage` (scheduled tasks), `LightAppManagementPage`/`LightAppListPage`/`LightAppIframeView` (轻应用中心 — register/list/embed mini-apps) +- **轻应用中心 (Light App Center)**: a sibling of 任务管理 in the sidebar with two pages — 应用管理 (admin registers `window_open` or `iframe` mini-apps with a route-param builder: login params resolved from `localStorage.userInfo`, theme params literal) and 应用列表 (card grid of `window_open` apps, `window.open` launch). `iframe` apps are mounted under a chosen parent menu (XK/PG/TY/综合管理) and **dynamically injected into the sidebar** by `core/page-layout/use-sidebar-menu.ts` (`useSidebarMenuItems`), opening in the `LightAppIframeView` host. Client + React Query hooks in `strategy-components/api/light-apps.ts`; URL builder/constants in `strategy-components/lib/light-app.ts`. Backend: `/api/light-apps` (global/shared, admin-gated writes). - **State**: TanStack Query for server state, context providers (`SubtasksProvider`, `ArtifactsProvider`, `PromptInputProvider`) scoped to `WorkspaceRoutes` - **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/` diff --git a/frontend-web/src/components/page-sidebar-dark.tsx b/frontend-web/src/components/page-sidebar-dark.tsx index 5d54ec6a..52cd2eed 100644 --- a/frontend-web/src/components/page-sidebar-dark.tsx +++ b/frontend-web/src/components/page-sidebar-dark.tsx @@ -1,14 +1,13 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useNavigate, useLocation } from "react-router-dom"; import { PanelLeftOpen, PanelLeftClose } from "lucide-react"; import { getStoredAuth } from "@/core/auth"; import { defaultExpandedIds, - filterMenuItemsByRole, - sidebarMenuItems, type MenuItem, } from "@/core/page-layout/sidebar-menu"; +import { useSidebarMenuItems } from "@/core/page-layout/use-sidebar-menu"; import './page-sdebar.css' type PageSidebarDarkProps = { @@ -19,10 +18,7 @@ type PageSidebarDarkProps = { export function PageSidebarDark({ collapsed, onToggleCollapsed }: PageSidebarDarkProps) { const isAdmin = getStoredAuth()?.system_role === "admin"; - const visibleMenuItems = useMemo( - () => filterMenuItemsByRole(sidebarMenuItems, !!isAdmin), - [isAdmin], - ); + const visibleMenuItems = useSidebarMenuItems(!!isAdmin); // defaultExpandedIds 是共享的 ReadonlySet,复制一份给 useState(避免外部突变) const [expandedIds, setExpandedIds] = useState>(() => new Set(defaultExpandedIds)); const navigate = useNavigate(); diff --git a/frontend-web/src/components/page-sidebar.tsx b/frontend-web/src/components/page-sidebar.tsx index 10512957..cae1f24f 100644 --- a/frontend-web/src/components/page-sidebar.tsx +++ b/frontend-web/src/components/page-sidebar.tsx @@ -26,10 +26,9 @@ import { useI18n } from "@/core/i18n/hooks"; import { useUnreadCount } from "@/core/notifications"; import { defaultExpandedIds, - filterMenuItemsByRole, - sidebarMenuItems, type MenuItem, } from "@/core/page-layout/sidebar-menu"; +import { useSidebarMenuItems } from "@/core/page-layout/use-sidebar-menu"; type PageSidebarProps = { collapsed: boolean; @@ -45,7 +44,7 @@ export function PageSidebar({ const { theme } = useTheme(); const isDark = theme === "dark"; const isAdmin = getStoredAuth()?.system_role === "admin"; - const visibleMenuItems = filterMenuItemsByRole(sidebarMenuItems, !!isAdmin); + const visibleMenuItems = useSidebarMenuItems(!!isAdmin); const { t } = useI18n(); const { data: unreadCount = 0 } = useUnreadCount(); // defaultExpandedIds 是共享的 ReadonlySet,复制一份给 useState(避免外部突变) diff --git a/frontend-web/src/core/page-layout/sidebar-menu.ts b/frontend-web/src/core/page-layout/sidebar-menu.ts index e8b99d6e..c68b5294 100644 --- a/frontend-web/src/core/page-layout/sidebar-menu.ts +++ b/frontend-web/src/core/page-layout/sidebar-menu.ts @@ -28,10 +28,7 @@ import { List, ListChecks, ListTodo, - MessageSquareCode, Network, - NotebookPen, - Plus, Settings, Sparkles, Target, @@ -72,40 +69,6 @@ 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: "task-management", label: "任务管理", @@ -207,6 +170,26 @@ export const sidebarMenuItems: MenuItem[] = [ }, ], }, + { + id: "light-app-center", + label: "轻应用中心", + icon: Grid3X3, + children: [ + { + id: "light-app-management", + label: "应用管理", + icon: Layout, + path: "/page/strategy/light-app/management", + adminOnly: true, + }, + { + id: "light-app-apps", + label: "应用列表", + icon: List, + path: "/page/strategy/light-app/apps", + }, + ], + }, { id: "comprehensive-management", label: "综合管理", diff --git a/frontend-web/src/core/page-layout/use-sidebar-menu.ts b/frontend-web/src/core/page-layout/use-sidebar-menu.ts new file mode 100644 index 00000000..1f2ec6b0 --- /dev/null +++ b/frontend-web/src/core/page-layout/use-sidebar-menu.ts @@ -0,0 +1,63 @@ +/** + * 侧边栏菜单合并 hook —— 把已注册的「iframe 类型」轻应用动态注入静态菜单。 + * + * 静态结构来自 sidebar-menu.ts;每个启用的 iframe 应用按其 `mountLocation`(父菜单 id) + * 作为新子项注入对应分组(XK中任务 / PG中任务 / TY中任务 / 综合管理),点击进入 + * iframe 宿主页 `/page/strategy/light-app/view/:appId`。 + * + * 浅色 PageSidebar 与深色 PageSidebarDark 共用此 hook,保证两套皮肤同步。 + */ +import { useMemo } from "react"; +import { Layout } from "lucide-react"; + +import { useLightApps, type LightApp } from "@/strategy-components/api/light-apps"; + +import { + filterMenuItemsByRole, + sidebarMenuItems, + type MenuItem, +} from "./sidebar-menu"; + +function lightAppLeaf(app: LightApp): MenuItem { + return { + id: `light-app-${app.appId}`, + label: app.name, + icon: Layout, + path: `/page/strategy/light-app/view/${app.appId}`, + }; +} + +/** 在菜单树(不可变克隆)中,把 iframe 应用按 mountLocation 注入对应父节点末尾。 */ +function injectLightApps(items: MenuItem[], apps: LightApp[]): MenuItem[] { + if (apps.length === 0) return items; + + const byMount = new Map(); + for (const app of apps) { + if (!app.mountLocation) continue; + const list = byMount.get(app.mountLocation) ?? []; + list.push(app); + byMount.set(app.mountLocation, list); + } + if (byMount.size === 0) return items; + + const walk = (nodes: MenuItem[]): MenuItem[] => + nodes.map((node) => { + const children = node.children ? walk(node.children) : undefined; + const extra = byMount.get(node.id); + if (!extra || extra.length === 0) { + return children ? { ...node, children } : node; + } + return { ...node, children: [...(children ?? []), ...extra.map(lightAppLeaf)] }; + }); + + return walk(items); +} + +/** 返回合并了 iframe 轻应用、并按 admin 角色过滤后的菜单。 */ +export function useSidebarMenuItems(isAdmin: boolean): MenuItem[] { + const { data: iframeApps = [] } = useLightApps({ appType: "iframe", enabledOnly: true }); + return useMemo( + () => filterMenuItemsByRole(injectLightApps(sidebarMenuItems, iframeApps), isAdmin), + [iframeApps, isAdmin], + ); +} diff --git a/frontend-web/src/core/strategy/external-links.ts b/frontend-web/src/core/strategy/external-links.ts index 2f9fd1fd..a4b62536 100644 --- a/frontend-web/src/core/strategy/external-links.ts +++ b/frontend-web/src/core/strategy/external-links.ts @@ -27,6 +27,50 @@ export function getNetworkActorIframeUrl(): string { return `${base}${path.startsWith("/") ? path : `/${path}`}`; } +/** 读取并解析 localStorage 中的 userInfo(登录信息)。 */ +function readUserInfo(): Record { + try { + const raw = localStorage.getItem("userInfo"); + if (!raw) return {}; + const parsed = JSON.parse(raw); + return parsed && typeof parsed === "object" ? (parsed as Record) : {}; + } catch { + return {}; + } +} + +/** 当前登录态 accessToken(无则空串)。 */ +export function getAccessToken(): string { + const value = readUserInfo().accessToken; + return value == null ? "" : String(value); +} + +/** + * XK 中任务 iframe 地址。 + * + * 形如 `{base}{gopath}?accessToken={token}`,其中 gopath 含 hash 路由 + * (如 `#/pageIndie/pageHome/pageSwrw`)—— 每次都从 base 重建,切换 gopath 不累加。 + */ +export function getXkIframeUrl(gopath: string): string { + const base = readEnv("VITE_AIPI_XKLINKTO_URL"); + if (!base) return ""; + return `${base}${gopath}?accessToken=${getAccessToken()}`; +} + +/** PG 中任务 iframe 地址:`{base}?access_token={token}&goPath={gopath}`。 */ +export function getPgIframeUrl(gopath: string): string { + const base = readEnv("VITE_AIPI_PGLINKTO_URL"); + if (!base) return ""; + return `${base}?access_token=${getAccessToken()}&goPath=${gopath}`; +} + +/** TY 中任务 iframe 地址:`{base}/fromB/toPage?defaultPage={defaultPage}`。 */ +export function getTyIframeUrl(defaultPage: string): string { + const base = trimTrailingSlash(readEnv("VITE_AIPI_B2ACTOR_URL")); + if (!base) return ""; + return `${base}/fromB/toPage?defaultPage=${defaultPage}`; +} + /** 知识库 iframe 地址换取 API */ export function getKnowledgeIframeApiUrl(): string { return readEnv("VITE_AIPI_KNOWLEDGE_IFRAME_API_URL"); 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 ddb0e752..983eec6d 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; -import { ChevronsDown, Play, Plus, RotateCcw, Settings2, Square, Trash2 } from 'lucide-react'; +import { ChevronsDown, PanelRightClose, Play, Plus, RotateCcw, Settings2, Square, Trash2 } from 'lucide-react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; @@ -15,6 +15,7 @@ import { useArticleTypes } from '../../hooks/useArticleTypes'; import { useAIWriting } from '../../contexts/AIWritingContext'; import { useAIWritingHistory } from '../../contexts/AIWritingHistoryContext'; +import { useWritingFormOptional } from '../../contexts/WritingFormContext'; import { useAIWritingSessions } from '../../hooks/useAIWritingSessions'; import { adminCleanupAIWritingSessions, getAIWritingSession } from '../../api/ai-writing-sessions'; @@ -83,6 +84,7 @@ export function AIWritingPanel({ const [searchParams, setSearchParams] = useSearchParams(); const { state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset } = useAIWriting(); const { historySession, isHistoryMode, openHistory, closeHistory } = useAIWritingHistory(); + const writingForm = useWritingFormOptional(); const { sessions, loading: sessionsLoading, refresh, rename, remove } = useAIWritingSessions(); const { articleTypes, refresh: refreshArticleTypes } = useArticleTypes(); @@ -342,6 +344,18 @@ export function AIWritingPanel({ onRename={rename} onRefresh={refresh} /> + {/* 收起写作配置:仅在 idle 配置阶段、表单展开时出现(与 AIWritingPage 的整块收起联动) */} + {writingForm && isIdle && !isHistoryMode && writingForm.formExpanded && ( + + )} diff --git a/frontend-web/src/open-canvas/contexts/WritingFormContext.tsx b/frontend-web/src/open-canvas/contexts/WritingFormContext.tsx index 41fe9198..b6d10241 100644 --- a/frontend-web/src/open-canvas/contexts/WritingFormContext.tsx +++ b/frontend-web/src/open-canvas/contexts/WritingFormContext.tsx @@ -19,6 +19,9 @@ import type { SetupWritingApprovalPayload } from '@/components/workspace/message export interface WritingFormValue { intent: string; setIntent: (v: string) => void; + /** 整个写作配置表单主体是否展开;初始收起,用户点表头或问答侧「应用到表单」时展开。 */ + formExpanded: boolean; + setFormExpanded: (v: boolean) => void; showOutline: boolean; setShowOutline: (v: boolean) => void; outlineText: string; @@ -55,6 +58,7 @@ export function useWritingFormOptional(): WritingFormValue | null { export function WritingFormProvider({ children, initialMaterialSource }: { children: ReactNode; initialMaterialSource?: 'knowledge_base' | 'skill' | 'notebook' }) { const [intent, setIntent] = useState(''); + const [formExpanded, setFormExpanded] = useState(false); const [showOutline, setShowOutline] = useState(false); const [outlineText, setOutlineText] = useState(''); const [articleType, setArticleType] = useState('analysis'); @@ -65,6 +69,8 @@ export function WritingFormProvider({ children, initialMaterialSource }: { child const [modelName, setModelName] = useState(''); const applyProposal = useCallback((payload: SetupWritingApprovalPayload) => { + // 问答侧识别出意图并点「应用到表单」时,自动展开表单让用户看到回填结果。 + setFormExpanded(true); if (payload.user_intent?.trim()) setIntent(payload.user_intent.trim()); if (payload.article_type?.trim()) setArticleType(payload.article_type.trim()); if (typeof payload.word_count_target === 'number' && payload.word_count_target > 0) { @@ -85,6 +91,7 @@ export function WritingFormProvider({ children, initialMaterialSource }: { child const value = useMemo( () => ({ intent, setIntent, + formExpanded, setFormExpanded, showOutline, setShowOutline, outlineText, setOutlineText, articleType, setArticleType, @@ -95,7 +102,7 @@ export function WritingFormProvider({ children, initialMaterialSource }: { child modelName, setModelName, applyProposal, }), - [intent, showOutline, outlineText, articleType, wordCountInput, audience, strictMode, materialSource, modelName, applyProposal], + [intent, formExpanded, showOutline, outlineText, articleType, wordCountInput, audience, strictMode, materialSource, modelName, applyProposal], ); return {children}; diff --git a/frontend-web/src/open-canvas/pages/AIWritingPage.tsx b/frontend-web/src/open-canvas/pages/AIWritingPage.tsx index 34c323db..fcab7b24 100644 --- a/frontend-web/src/open-canvas/pages/AIWritingPage.tsx +++ b/frontend-web/src/open-canvas/pages/AIWritingPage.tsx @@ -82,7 +82,9 @@ * ────────────────────────────────────────────────────────────────────────── */ import { useRef } from 'react'; +import { ChevronLeft } from 'lucide-react'; import { TooltipProvider } from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; import type { GroupImperativeHandle } from 'react-resizable-panels'; import { CanvasThreadProvider } from '../contexts/CanvasThreadContext'; @@ -93,7 +95,7 @@ import { AIWritingDraftPanel } from '../components/ai-writing/AIWritingDraftPane import { WritingSetupChat } from '../components/ai-writing/WritingSetupChat'; import { AIWritingProvider, useAIWriting } from '../contexts/AIWritingContext'; import { AIWritingHistoryProvider, useAIWritingHistory } from '../contexts/AIWritingHistoryContext'; -import { WritingFormProvider } from '../contexts/WritingFormContext'; +import { WritingFormProvider, useWritingForm } from '../contexts/WritingFormContext'; import { ResizableHandle, ResizablePanel, @@ -103,14 +105,9 @@ import { // Panel IDs used as keys in the v4 Layout map const WRITING_ID = 'aw-writing'; const DRAFT_ID = 'aw-draft'; -// 初始问答阶段(idle)专用的两栏 ID:左=问答助手,右=写作配置表单 -const SETUP_CHAT_ID = 'aw-setup-chat'; -const SETUP_FORM_ID = 'aw-setup-form'; // Match the original canvas page proportions (chat=35, artifact=65) const DEFAULT_LAYOUT = { [WRITING_ID]: 35, [DRAFT_ID]: 65 }; -// idle 阶段镜像原比例:问答框在左占大头(65),配置表单在右占小头(35) -const SETUP_LAYOUT = { [SETUP_CHAT_ID]: 65, [SETUP_FORM_ID]: 35 }; /** * 写作配置表单面板(写作助手回填的目标),抽出复用:idle 时在右栏,开始写作后在左栏。 @@ -139,38 +136,45 @@ function AIWritingLayout({ hideAdvancedFields, initialNotebookId, initialDocumen const layoutRef = useRef(null); const { state } = useAIWriting(); const { isHistoryMode } = useAIWritingHistory(); + const { formExpanded, setFormExpanded } = useWritingForm(); - // 初始问答阶段:无历史查看;查看历史时与「正在写作」相同左右分栏(左时间线、右草稿) + // 初始问答阶段:无历史查看;查看历史时与「正在写作」相同左右分栏(左时间线、右草稿)。 + // + // 用稳定的 flex 结构(而非按 formExpanded 在两套容器间切换):左侧问答区永远是第一个 + // 子节点、位置不变,避免收起/展开时 WritingSetupChat 被重新挂载(它在内部 useMemo 生成 + // 独立 threadId,一旦重挂会换新线程、丢失对话)。右侧整块「写作配置表单」可收起为一条 + // 竖向展开栏;问答侧点「应用到表单」(applyProposal) 会自动把 formExpanded 置 true 展开。 if (state.status === 'idle' && !isHistoryMode) { return ( - - {/* 左侧:写作助手 Q&A */} - +
+ {/* 左侧:写作助手 Q&A —— 恒为第一个子节点,位置稳定不重挂 */} +
- +
- - - {/* 右侧:写作配置选项表单 */} - - - - + {formExpanded ? ( + + ) : ( + + )} +
+ ); } diff --git a/frontend-web/src/pages/LightAppIframeView.tsx b/frontend-web/src/pages/LightAppIframeView.tsx new file mode 100644 index 00000000..02bd5cbb --- /dev/null +++ b/frontend-web/src/pages/LightAppIframeView.tsx @@ -0,0 +1,41 @@ +/** + * 轻应用 iframe 宿主页 —— 渲染某个已注册 iframe 类型应用。 + * + * 路由:/page/strategy/light-app/view/:appId。侧边栏动态注入的 iframe 应用子项指向此页。 + * 用 buildLightAppUrl 构造带路由参数(登录态 / 主题)的 src。 + */ +import { useMemo } from "react"; +import { useParams } from "react-router-dom"; + +import { useTheme } from "@/contexts/ThemeContext"; +import { useLightApp } from "@/strategy-components/api/light-apps"; +import { buildLightAppUrl } from "@/strategy-components/lib/light-app"; + +export default function LightAppIframeView() { + const { appId } = useParams<{ appId: string }>(); + const { theme } = useTheme(); + const isDark = theme === "dark"; + const { data: app, isLoading, error } = useLightApp(appId); + + const src = useMemo(() => (app ? buildLightAppUrl(app) : ""), [app]); + + const centerClass = `flex h-full w-full items-center justify-center text-sm ${ + isDark ? "text-slate-400" : "text-slate-500" + }`; + + if (isLoading) { + return
加载中…
; + } + if (error || !app) { + return
应用不存在或已被删除
; + } + if (app.appType !== "iframe") { + return
该应用不是 iframe 嵌套类型
; + } + + return ( +
+ -
- ); -}; - -export default FanganTyPage; diff --git a/frontend-web/src/strategy-components/components/task-components/TaskPgFrom.tsx b/frontend-web/src/strategy-components/components/task-components/TaskPgFrom.tsx index 4a4a3287..88472265 100644 --- a/frontend-web/src/strategy-components/components/task-components/TaskPgFrom.tsx +++ b/frontend-web/src/strategy-components/components/task-components/TaskPgFrom.tsx @@ -1,24 +1,16 @@ -import { Outlet, useNavigate } from 'react-router-dom'; -import React, { useState, useEffect, useRef } from 'react'; -import { useSearchParams } from 'react-router-dom'; +import React, { useMemo } from "react"; + +import { getPgIframeUrl } from "@/core/strategy/external-links"; +import ExternalIframePage from "../common/ExternalIframePage"; -const userInfo = localStorage.getItem('userInfo'); -const pgLinkUrl = import.meta.env.VITE_AIPI_PGLINKTO_URL; interface TaskPgFromProps { - gopath: string + gopath: string; } -const TaskPgFrom: React.FC = ({gopath}) => { -const [pgUrl, setPgUrl] = useState(pgLinkUrl); - useEffect(() => { - if(userInfo) { - const userData = JSON.parse(userInfo); - setPgUrl(`${pgUrl}?access_token=${userData.accessToken}&goPath=${gopath}`); - } - }, [gopath]); - return ( -
- -
- ); -} -export default TaskPgFrom + +const TaskPgFrom: React.FC = ({ gopath }) => { + // 每次从 base 重建(修复原先用状态做模板导致查询串累加的 bug);key={src} 强制重载。 + const src = useMemo(() => getPgIframeUrl(gopath), [gopath]); + return ; +}; + +export default TaskPgFrom; diff --git a/frontend-web/src/strategy-components/components/task-components/TaskTyFrom.tsx b/frontend-web/src/strategy-components/components/task-components/TaskTyFrom.tsx new file mode 100644 index 00000000..cc781431 --- /dev/null +++ b/frontend-web/src/strategy-components/components/task-components/TaskTyFrom.tsx @@ -0,0 +1,18 @@ +import React, { useMemo } from "react"; + +import { getTyIframeUrl } from "@/core/strategy/external-links"; +import ExternalIframePage from "../common/ExternalIframePage"; + +interface TaskTyFromProps { + /** TY 系统页标识:iframeXdty(行动)/ iframeFaty(方案)/ iframeSysystem(综合)。 */ + defaultPage: string; + title?: string; +} + +/** TY 中任务统一入口(原 XingdongTy / FanganTy / XXTy 合并)。 */ +const TaskTyFrom: React.FC = ({ defaultPage, title }) => { + const src = useMemo(() => getTyIframeUrl(defaultPage), [defaultPage]); + return ; +}; + +export default TaskTyFrom; diff --git a/frontend-web/src/strategy-components/components/task-components/TaskXkFrom.tsx b/frontend-web/src/strategy-components/components/task-components/TaskXkFrom.tsx index 832dffa5..c95f750d 100644 --- a/frontend-web/src/strategy-components/components/task-components/TaskXkFrom.tsx +++ b/frontend-web/src/strategy-components/components/task-components/TaskXkFrom.tsx @@ -1,23 +1,16 @@ -import { Outlet, useNavigate } from 'react-router-dom'; -import React, { useState, useEffect, useRef } from 'react'; -import { useSearchParams } from 'react-router-dom'; -const userInfo = localStorage.getItem('userInfo'); -const xkLinkUrl = import.meta.env.VITE_AIPI_XKLINKTO_URL; -interface TaskPgFromProps { - gopath: string +import React, { useMemo } from "react"; + +import { getXkIframeUrl } from "@/core/strategy/external-links"; +import ExternalIframePage from "../common/ExternalIframePage"; + +interface TaskXkFromProps { + gopath: string; } -const TaskXkFrom: React.FC = ({gopath}) => { -const [xkUrl, setXkUrl] = useState(xkLinkUrl); - useEffect(() => { - if(userInfo) { - const userData: any = JSON.parse(userInfo); - setXkUrl(`${xkLinkUrl}${gopath}?accessToken=${userData.accessToken}`); - } - }, [gopath]); - return ( -
- -
- ); -} -export default TaskXkFrom + +const TaskXkFrom: React.FC = ({ gopath }) => { + // gopath 变化(菜单切换)时从 base 重建地址;ExternalIframePage 的 key={src} 会强制重载。 + const src = useMemo(() => getXkIframeUrl(gopath), [gopath]); + return ; +}; + +export default TaskXkFrom; diff --git a/frontend-web/src/strategy-components/components/task-components/XXTy.tsx b/frontend-web/src/strategy-components/components/task-components/XXTy.tsx deleted file mode 100644 index 51abae74..00000000 --- a/frontend-web/src/strategy-components/components/task-components/XXTy.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import React, { useEffect, useState } from "react"; -import "./css/iframe.css"; - -import { useTheme } from "@/contexts/ThemeContext"; - -const TYURL = import.meta.env.VITE_AIPI_B2ACTOR_URL || ""; - -const XXTyPage: React.FC = () => { - const { theme } = useTheme(); - const isDark = theme === "dark"; - const [iframeUrl, setIframeUrl] = useState(""); - useEffect(() => { - initIframeUrl(); - }, []); - const initIframeUrl = () => { - const url = `${TYURL}/fromB/toPage?defaultPage=iframeSysystem`; - console.log({ iframeUrl: url }); - setIframeUrl(url); - }; - return ( -
- -
- ); -}; - -export default XXTyPage; diff --git a/frontend-web/src/strategy-components/components/task-components/XingdongTy.tsx b/frontend-web/src/strategy-components/components/task-components/XingdongTy.tsx deleted file mode 100644 index 98322871..00000000 --- a/frontend-web/src/strategy-components/components/task-components/XingdongTy.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import React, { useEffect, useState } from "react"; -import "./css/iframe.css"; - -import { useTheme } from "@/contexts/ThemeContext"; - -const TYURL = import.meta.env.VITE_AIPI_B2ACTOR_URL || ""; - -const XingdongTyPage: React.FC = () => { - const { theme } = useTheme(); - const isDark = theme === "dark"; - const [iframeUrl, setIframeUrl] = useState(""); - useEffect(() => { - initIframeUrl(); - }, []); - const initIframeUrl = () => { - const url = `${TYURL}/fromB/toPage?defaultPage=iframeXdty`; - console.log({ iframeUrl: url }); - setIframeUrl(url); - }; - return ( -
- -
- ); -}; - -export default XingdongTyPage; diff --git a/frontend-web/src/strategy-components/lib/light-app.ts b/frontend-web/src/strategy-components/lib/light-app.ts new file mode 100644 index 00000000..b2066f47 --- /dev/null +++ b/frontend-web/src/strategy-components/lib/light-app.ts @@ -0,0 +1,84 @@ +/** + * 轻应用共享常量 + 运行时 URL 构造(管理页预览 / 列表页跳转 / iframe 宿主共用)。 + * + * 登录参数运行时从 `localStorage.getItem("userInfo")`(JSON) 解析;主题参数用配置值。 + */ + +import type { LightApp, LightAppRouteParam } from "@/strategy-components/api/light-apps"; + +/** iframe 可挂载的父菜单(取自 core/page-layout/sidebar-menu.ts 的现有菜单 id)。 */ +export const MOUNT_OPTIONS: { label: string; value: string }[] = [ + { label: "XK中任务", value: "xk-center-task" }, + { label: "PG中任务", value: "pg-center-task" }, + { label: "TY中任务", value: "ty-center-task" }, + { label: "资源管理", value: "resource-management" }, +]; + +export function mountLabel(value: string | null | undefined): string { + if (!value) return ""; + return MOUNT_OPTIONS.find((o) => o.value === value)?.label ?? value; +} + +/** 登录参数可选来源;值均可在 userInfo 中取到,"other" 表示自填。 */ +export const LOGIN_SOURCE_OPTIONS: { label: string; value: string }[] = [ + { label: "accessToken", value: "accessToken" }, + { label: "yUserId", value: "yUserId" }, + { label: "userName", value: "userName" }, + { label: "其他(自定义值)", value: "other" }, +]; + +/** 主题参数示例值(深蓝 / 红 / 蓝),可改。 */ +export const THEME_EXAMPLE_OPTIONS: { label: string; value: string }[] = [ + { label: "深蓝色 dark-blue", value: "dark-blue" }, + { label: "红色 red", value: "red" }, + { label: "蓝色 blue", value: "blue" }, +]; + +/** 读取并解析 localStorage 中的 userInfo。 */ +export function readUserInfo(): Record { + try { + const raw = localStorage.getItem("userInfo"); + if (!raw) return {}; + const parsed = JSON.parse(raw); + return parsed && typeof parsed === "object" ? (parsed as Record) : {}; + } catch { + return {}; + } +} + +/** 解析单个路由参数在运行时的取值。 */ +export function resolveRouteParamValue( + param: LightAppRouteParam, + userInfo: Record = readUserInfo(), +): string { + if (param.paramType === "theme") { + return param.themeValue ?? ""; + } + // login + if (param.source === "other") { + return param.customValue ?? ""; + } + if (param.source) { + const value = userInfo[param.source]; + return value == null ? "" : String(value); + } + return ""; +} + +/** + * 构造应用的最终访问 URL:在 `app.url` 上追加解析后的路由参数查询串。 + * + * 兼容 hash 路由地址(如 `https://x#/route`)—— 管理员粘贴完整地址,参数自动追加在末尾, + * 落在 hash 路由的 query 上(与现有 XK/PG 页面 `?accessToken=` 拼接方式一致)。 + */ +export function buildLightAppUrl( + app: Pick, + userInfo: Record = readUserInfo(), +): string { + const pairs = (app.routeParams ?? []) + .filter((p) => p.key) + .map((p) => `${encodeURIComponent(p.key)}=${encodeURIComponent(resolveRouteParamValue(p, userInfo))}`); + if (pairs.length === 0) return app.url; + const sep = app.url.includes("?") ? "&" : "?"; + return `${app.url}${sep}${pairs.join("&")}`; +} diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index 647aa236..65a8c0c2 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -251,6 +251,7 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA | **Scheduled Tasks** (`/api/scheduled-tasks`) | Per-user cron-like agent tasks. `GET/POST /` - list/create owned tasks; `PATCH/DELETE /{id}` - update/delete (owner); `POST /{id}/{pause,resume,trigger}`; `GET /{id}/runs`, `GET /runs/{rid}`, run-file preview/edit/download; `GET /runs/{rid}/messages` - agent messages for the run's underlying agent run (used to inspect a stuck/running run; authorized via `_load_run`); `GET /runs/{rid}/html` - generated HTML document for an `html_page` run; `DELETE /runs/{rid}` - owner-scoped, with an admin fallback so admins can delete any user's run; publish/subscribe under `/{id}/{publish,unpublish,subscribe}` and `GET /published`. **Admin (cross-user):** `GET /admin/all` - every task; `PATCH/DELETE /admin/{id}` - edit/delete any task; `POST /admin/{id}/trigger`; `GET /admin/{id}/runs`. Admin routes require `system_role == "admin"`. | | **HTML Page Favorites** (`/api/html-page-favorites`) | Bookmarked generated HTML pages, owner-scoped. `GET /` - list; `POST /` `{page_id, title?, description?, tags?}` - bookmark a `scheduled_html_pages` row (extracts a reusable style/layout summary via GLM5 at favorite time); a page can only be favorited once per user (`DuplicateFavoriteError` → HTTP 409). `GET/DELETE /{id}` - read/delete (the create-task reference picker deletes favorites here). A favorite can be selected as a style reference when creating a new `html_page` task. | | **Public Scheduled Tasks** (`/api/public/scheduled-tasks`) | **Unauthenticated** mirror for embedding. Adds `GET /runs/{rid}/html` and `GET /{task_id}/latest-html` for the `html_page` preview; the run result keeps `result.html` (only file paths are redacted). | +| **Light Apps** (`/api/light-apps`) | Light-app registry (轻应用中心 / 应用管理), **global/shared** (no user scoping; `created_by`/`updated_by` audit-only). Two kinds: `window_open` (launched in a new tab from the 应用列表 card grid) and `iframe` (embedded under a parent sidebar menu via `mount_location` and dynamically injected into the sidebar). Both carry `route_params` — a list of `{key, paramType: login\|theme, source, customValue, themeValue}` descriptors the **frontend** resolves at launch time (login params read from `localStorage.userInfo`; theme params use a literal value) and appends to `url` as query string. `GET /` - list (filters `app_type`/`mount_location`/`enabled_only`/`search`); `GET /{app_id}` - read one (both open to any authenticated user); `POST /`, `PUT /{app_id}`, `DELETE /{app_id}` - **admin only**; iframe apps must carry a `mount_location` (400 otherwise); the unique `name` collides as HTTP 409. Backed by `deerflow.persistence.light_apps` (table `light_apps`, auto-created by `create_all`); store wired in `deps.py` as `app.state.light_app_store`. Tests: `tests/test_light_apps.py`. | | **Knowledge** (`/api/knowledge`) | Product knowledge base, **global/shared** (no user isolation; writes record `created_by`/`updated_by` for audit). `POST /ingest/thread/{thread_id}` - sediment one thread into a note (reads messages from the checkpointer, `threads:read` owner-checked); `POST /ingest/threads/batch` - batch-sediment the caller's recent threads (`skip_existing` via `.manifest.json` dedup). **Optional model selection**: both ingest endpoints accept `model_name` (overrides `knowledge.extract_model_name` for that capture; threaded through `KnowledgeService.capture_thread` → `extract_thread_llm_multi`). **Model-error surfacing**: when the chosen model produces nothing usable (timeout / API error / non-JSON) capture still **falls back to rule-based extraction and lands a draft** (`llm_fallback:true`); the single-thread (non-background) response and the batch response (`llm_fallback` count) carry this so the UI tells the user "已改用规则抽取". The "保存到知识库" chat button stays background/fire-and-forget (passes `model_name`, no per-save error toast); the batch-import dialog is synchronous and shows the fallback count. `POST /ingest/file` - **import an uploaded file** (multipart: `file` + optional `title`/`tags`/`status`/`model_name`) into wiki note(s): the file is converted to Markdown (md/txt read directly, pdf/word/ppt/excel via `convert_file_to_markdown`), distilled by the same wiki-capture LLM pipeline as thread capture (`source_type="file"`), and falls back to a verbatim **draft** note when the model returns nothing usable (`llm_fallback:true`); `POST /ingest/search` - sediment search/tool results as a reference note; `POST /notes` - manual note; `GET /notes` (keyword/tag/source_type/status + pagination); `GET/PUT/DELETE /notes/{id}` (DELETE is soft = archive, `?hard=true` removes the vault file); `POST /search` - `mode=keyword\|vector\|hybrid` (vector→keyword fallback when no embedding endpoint); `GET /notes/{id}/versions` - edit history; `GET /duplicates` + `POST /merge` - dedup; `POST /reindex` - rebuild vector index; `GET /graph` - product graph data (notes + entities, graph-blacklist filtered); `GET/POST /graph/blacklist` + `DELETE /graph/blacklist/{id}` - graph node blacklist (labels = entity names / note titles, case-insensitive match; blacklisted nodes and their edges are dropped from `/graph` and the wiki-export artifacts; table `knowledge_graph_blacklist`, auto-created); `POST /export/graph` + `GET /export/files/{graph.json\|graph.html\|graph.graphml\|cypher.txt}` - obsidian-wiki `wiki-export` artifacts (self-contained `graph.html` for iframe); `POST /resolve` `{targets:[…]}` + `GET /notes/{id}/wikilinks` - resolve `[[wikilink]]` targets to a note id / vault page / missing (powers clickable wikilinks); `GET /vault-page?path=` - read a raw vault meta/hub Markdown page (no DB note). Backed by the `deerflow.knowledge` module (see below). | Proxied through nginx: `/api/langgraph/*` → LangGraph, all other `/api/*` → Gateway. diff --git a/offline-backend-20260512/backend/app/gateway/app.py b/offline-backend-20260512/backend/app/gateway/app.py index 2a999974..6b62a111 100644 --- a/offline-backend-20260512/backend/app/gateway/app.py +++ b/offline-backend-20260512/backend/app/gateway/app.py @@ -40,6 +40,7 @@ from app.gateway.routers import ( html_page_favorites, intent, knowledge, + light_apps, llm_metrics, mcp, memory, @@ -1321,6 +1322,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an # Global business → agent / chain mapping at /api/business-mapping app.include_router(business_mapping.router) + # Light-app registry (轻应用中心) at /api/light-apps + app.include_router(light_apps.router) + # Standalone parallel multi-agent panel: fan-out streaming + saved tasks app.include_router(parallel_agents.router) app.include_router(parallel_agent_tasks.router) diff --git a/offline-backend-20260512/backend/app/gateway/deps.py b/offline-backend-20260512/backend/app/gateway/deps.py index 62ae4daa..03a98168 100644 --- a/offline-backend-20260512/backend/app/gateway/deps.py +++ b/offline-backend-20260512/backend/app/gateway/deps.py @@ -204,6 +204,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]: app.state.business_mapping_store = make_business_mapping_store(sf) + from deerflow.persistence.light_apps import make_light_app_store + + app.state.light_app_store = make_light_app_store(sf) + from deerflow.persistence.parallel_agent_tasks import make_parallel_agent_task_store app.state.parallel_agent_task_store = make_parallel_agent_task_store(sf) diff --git a/offline-backend-20260512/backend/app/gateway/routers/light_apps.py b/offline-backend-20260512/backend/app/gateway/routers/light_apps.py new file mode 100644 index 00000000..dce48506 --- /dev/null +++ b/offline-backend-20260512/backend/app/gateway/routers/light_apps.py @@ -0,0 +1,216 @@ +"""Light-app registry API (轻应用中心 / 应用管理). + +Registered mini-applications, **global / shared** (no user scoping). Two kinds: + +- ``window_open`` — launched in a new browser tab from the 应用列表 card grid. +- ``iframe`` — embedded under a parent sidebar menu (``mount_location``) and + injected into the sidebar as a child item. + +Both kinds carry ``route_params`` — a list of descriptors the frontend resolves +at launch time (login params read from ``localStorage.userInfo``; theme params +use a literal value) and appends to ``url`` as query string. + +Reads are open to any authenticated user; writes require admin. + +Routes (prefix ``/api/light-apps``): + GET "" list apps (filters: app_type / mount_location / enabled_only / search) + POST "" create app (admin) + GET "/{app_id}" read one + PUT "/{app_id}" update one (admin) + DELETE "/{app_id}" delete one (admin) +""" + +from __future__ import annotations + +from datetime import datetime + +from fastapi import APIRouter, HTTPException, Query, Request +from pydantic import BaseModel, Field + +from app.gateway.deps import get_current_user, get_optional_user_from_request +from deerflow.persistence.light_apps import DuplicateLightAppNameError, LightAppStore + +router = APIRouter(prefix="/api/light-apps", tags=["light-apps"]) + +_VALID_TYPES = {"window_open", "iframe"} + + +# ── schemas ────────────────────────────────────────────────────────────── + + +class RouteParam(BaseModel): + key: str = Field(..., min_length=1, max_length=128) + paramType: str = Field(..., pattern="^(login|theme)$") + # login: which userInfo field, or "other" + source: str | None = Field(default=None, max_length=64) + # login + source == "other": literal value + customValue: str | None = Field(default=None, max_length=2048) + # theme: the theme value (e.g. dark-blue / red / blue), editable + themeValue: str | None = Field(default=None, max_length=128) + + +class LightAppResponse(BaseModel): + app_id: str + name: str + description: str | None = None + app_type: str + url: str + route_params: list[RouteParam] = Field(default_factory=list) + mount_location: str | None = None + icon: str | None = None + enabled: bool = True + sort_order: int = 0 + created_by: str | None = None + updated_by: str | None = None + created_at: datetime | str | None = None + updated_at: datetime | str | None = None + + +class LightAppListResponse(BaseModel): + apps: list[LightAppResponse] + + +class LightAppCreateRequest(BaseModel): + name: str = Field(..., min_length=1, max_length=127) + description: str | None = Field(default=None, max_length=1000) + app_type: str = Field(..., pattern="^(window_open|iframe)$") + url: str = Field(..., min_length=1) + route_params: list[RouteParam] = Field(default_factory=list) + mount_location: str | None = Field(default=None, max_length=64) + icon: str | None = Field(default=None, max_length=64) + enabled: bool = True + sort_order: int = 0 + + +class LightAppUpdateRequest(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=127) + description: str | None = Field(default=None, max_length=1000) + app_type: str | None = Field(default=None, pattern="^(window_open|iframe)$") + url: str | None = Field(default=None, min_length=1) + route_params: list[RouteParam] | None = None + mount_location: str | None = Field(default=None, max_length=64) + icon: str | None = Field(default=None, max_length=64) + enabled: bool | None = None + sort_order: int | None = None + + +# ── helpers ────────────────────────────────────────────────────────────── + + +def _get_store(request: Request) -> LightAppStore: + store = getattr(request.app.state, "light_app_store", None) + if store is None: + raise HTTPException(status_code=503, detail="Light app store not available") + return store + + +async def _require_admin(request: Request) -> None: + """Reject non-admins. Lets the call through when auth is disabled.""" + user = await get_optional_user_from_request(request) + if user is None: + return + if getattr(user, "system_role", None) != "admin": + raise HTTPException(status_code=403, detail="轻应用管理仅限管理员") + + +def _validate_payload(app_type: str | None, mount_location: str | None) -> None: + if app_type == "iframe" and not mount_location: + raise HTTPException(status_code=400, detail="iframe 类型必须选择挂载菜单") + + +def _dump_params(params) -> list[dict]: + return [p.model_dump() if isinstance(p, RouteParam) else dict(p) for p in (params or [])] + + +# ── routes ─────────────────────────────────────────────────────────────── + + +@router.get("", response_model=LightAppListResponse) +async def list_light_apps( + request: Request, + app_type: str | None = Query(default=None), + mount_location: str | None = Query(default=None), + enabled_only: bool = Query(default=False), + search: str | None = Query(default=None), +) -> LightAppListResponse: + store = _get_store(request) + rows = await store.list_apps(app_type=app_type, mount_location=mount_location, enabled_only=enabled_only) + if search: + needle = search.strip().lower() + rows = [r for r in rows if needle in str(r.get("name", "")).lower() or needle in str(r.get("description") or "").lower()] + return LightAppListResponse(apps=[LightAppResponse(**r) for r in rows]) + + +@router.post("", response_model=LightAppResponse) +async def create_light_app(request: Request, body: LightAppCreateRequest) -> LightAppResponse: + await _require_admin(request) + _validate_payload(body.app_type, body.mount_location) + store = _get_store(request) + user_id = await get_current_user(request) + try: + row = await store.create_app( + { + "name": body.name, + "description": body.description, + "app_type": body.app_type, + "url": body.url, + "route_params": _dump_params(body.route_params), + "mount_location": body.mount_location if body.app_type == "iframe" else None, + "icon": body.icon, + "enabled": body.enabled, + "sort_order": body.sort_order, + "created_by": user_id, + } + ) + except DuplicateLightAppNameError: + raise HTTPException(status_code=409, detail="应用名称已存在") + return LightAppResponse(**row) + + +@router.get("/{app_id}", response_model=LightAppResponse) +async def get_light_app(request: Request, app_id: str) -> LightAppResponse: + store = _get_store(request) + row = await store.get_app(app_id) + if row is None: + raise HTTPException(status_code=404, detail="应用不存在") + return LightAppResponse(**row) + + +@router.put("/{app_id}", response_model=LightAppResponse) +async def update_light_app(request: Request, app_id: str, body: LightAppUpdateRequest) -> LightAppResponse: + await _require_admin(request) + store = _get_store(request) + user_id = await get_current_user(request) + + fields = body.model_dump(exclude_unset=True) + if "route_params" in fields: + fields["route_params"] = _dump_params(body.route_params) + + # Resolve the effective type/mount to validate the iframe→mount invariant. + existing = await store.get_app(app_id) + if existing is None: + raise HTTPException(status_code=404, detail="应用不存在") + eff_type = fields.get("app_type", existing.get("app_type")) + eff_mount = fields.get("mount_location", existing.get("mount_location")) + _validate_payload(eff_type, eff_mount) + if eff_type == "window_open": + fields["mount_location"] = None + + fields["updated_by"] = user_id + try: + row = await store.update_app(app_id, fields) + except DuplicateLightAppNameError: + raise HTTPException(status_code=409, detail="应用名称已存在") + if row is None: + raise HTTPException(status_code=404, detail="应用不存在") + return LightAppResponse(**row) + + +@router.delete("/{app_id}") +async def delete_light_app(request: Request, app_id: str) -> dict: + await _require_admin(request) + store = _get_store(request) + deleted = await store.delete_app(app_id) + if not deleted: + raise HTTPException(status_code=404, detail="应用不存在") + return {"success": True} diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/light_apps/__init__.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/light_apps/__init__.py new file mode 100644 index 00000000..3818b7f0 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/light_apps/__init__.py @@ -0,0 +1,23 @@ +"""Light-app registry persistence (轻应用中心).""" + +from __future__ import annotations + +from deerflow.persistence.light_apps.base import DuplicateLightAppNameError, LightAppStore +from deerflow.persistence.light_apps.memory import MemoryLightAppStore +from deerflow.persistence.light_apps.model import LightAppRow +from deerflow.persistence.light_apps.sql import LightAppRepository + +__all__ = [ + "DuplicateLightAppNameError", + "LightAppRepository", + "LightAppRow", + "LightAppStore", + "MemoryLightAppStore", + "make_light_app_store", +] + + +def make_light_app_store(session_factory): + if session_factory is None: + return MemoryLightAppStore() + return LightAppRepository(session_factory) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/light_apps/base.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/light_apps/base.py new file mode 100644 index 00000000..2ba8bf6d --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/light_apps/base.py @@ -0,0 +1,46 @@ +"""Abstract light-app registry persistence API. + +Global / shared store: no ``user_id`` scoping. Reads are open to any caller; the +router gates writes behind admin. ``created_by`` / ``updated_by`` are audit-only. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any + + +class DuplicateLightAppNameError(ValueError): + """Raised when creating/updating an app would collide on the unique name.""" + + +class LightAppStore(ABC): + @abstractmethod + async def list_apps( + self, + *, + app_type: str | None = None, + mount_location: str | None = None, + enabled_only: bool = False, + ) -> list[dict[str, Any]]: + """Return registered apps (newest first), optionally filtered.""" + raise NotImplementedError + + @abstractmethod + async def get_app(self, app_id: str) -> dict[str, Any] | None: + raise NotImplementedError + + @abstractmethod + async def create_app(self, data: dict[str, Any]) -> dict[str, Any]: + """Create an app. Raises ``DuplicateLightAppNameError`` on a name clash.""" + raise NotImplementedError + + @abstractmethod + async def update_app(self, app_id: str, data: dict[str, Any]) -> dict[str, Any] | None: + """Partial update. Returns None if the app does not exist. Raises + ``DuplicateLightAppNameError`` when renaming onto an existing name.""" + raise NotImplementedError + + @abstractmethod + async def delete_app(self, app_id: str) -> bool: + raise NotImplementedError diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/light_apps/memory.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/light_apps/memory.py new file mode 100644 index 00000000..e0416c47 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/light_apps/memory.py @@ -0,0 +1,97 @@ +"""In-memory light-app registry store (used when no DB is configured / tests).""" + +from __future__ import annotations + +import uuid +from datetime import UTC, datetime +from typing import Any + +from deerflow.persistence.light_apps.base import DuplicateLightAppNameError, LightAppStore + +_WRITABLE = { + "name", + "description", + "app_type", + "url", + "route_params", + "mount_location", + "icon", + "enabled", + "sort_order", + "updated_by", +} + + +class MemoryLightAppStore(LightAppStore): + def __init__(self) -> None: + self._apps: dict[str, dict[str, Any]] = {} + + @staticmethod + def _public(row: dict[str, Any]) -> dict[str, Any]: + out = dict(row) + out["route_params"] = list(out.get("route_params") or []) + return out + + def _name_taken(self, name: str, *, exclude_id: str | None = None) -> bool: + return any(r["name"] == name and r["app_id"] != exclude_id for r in self._apps.values()) + + async def list_apps( + self, + *, + app_type: str | None = None, + mount_location: str | None = None, + enabled_only: bool = False, + ) -> list[dict[str, Any]]: + rows = list(self._apps.values()) + if app_type: + rows = [r for r in rows if r["app_type"] == app_type] + if mount_location: + rows = [r for r in rows if r.get("mount_location") == mount_location] + if enabled_only: + rows = [r for r in rows if r.get("enabled", True)] + rows.sort(key=lambda r: (r.get("sort_order", 0), r.get("created_at", ""))) + return [self._public(r) for r in rows] + + async def get_app(self, app_id: str) -> dict[str, Any] | None: + row = self._apps.get(app_id) + return self._public(row) if row is not None else None + + async def create_app(self, data: dict[str, Any]) -> dict[str, Any]: + if self._name_taken(data["name"]): + raise DuplicateLightAppNameError(data["name"]) + now = datetime.now(UTC).isoformat() + app_id = data.get("app_id") or uuid.uuid4().hex[:12] + row = { + "app_id": app_id, + "name": data["name"], + "description": data.get("description"), + "app_type": data["app_type"], + "url": data["url"], + "route_params": data.get("route_params") or [], + "mount_location": data.get("mount_location"), + "icon": data.get("icon"), + "enabled": data.get("enabled", True), + "sort_order": data.get("sort_order", 0), + "created_by": data.get("created_by"), + "updated_by": data.get("created_by"), + "created_at": now, + "updated_at": now, + } + self._apps[app_id] = row + return self._public(row) + + async def update_app(self, app_id: str, data: dict[str, Any]) -> dict[str, Any] | None: + row = self._apps.get(app_id) + if row is None: + return None + new_name = data.get("name") + if new_name is not None and new_name != row["name"] and self._name_taken(new_name, exclude_id=app_id): + raise DuplicateLightAppNameError(new_name) + for key, value in data.items(): + if key in _WRITABLE: + row[key] = value + row["updated_at"] = datetime.now(UTC).isoformat() + return self._public(row) + + async def delete_app(self, app_id: str) -> bool: + return self._apps.pop(app_id, None) is not None diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/light_apps/model.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/light_apps/model.py new file mode 100644 index 00000000..2fd7287b --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/light_apps/model.py @@ -0,0 +1,42 @@ +"""ORM model for the light-app registry (轻应用中心). + +Global / shared — there is no ``user_id`` scoping; every account sees the same +registered apps. ``created_by`` / ``updated_by`` are audit-only. +""" + +from __future__ import annotations + +from datetime import UTC, datetime + +from sqlalchemy import Boolean, Integer, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from deerflow.persistence.base import Base +from deerflow.persistence.types import BeijingDateTime, PortableJSON + + +class LightAppRow(Base): + __tablename__ = "light_apps" + __table_args__ = (UniqueConstraint("name", name="uq_light_apps_name"),) + + app_id: Mapped[str] = mapped_column(String(64), primary_key=True) + name: Mapped[str] = mapped_column(String(127)) + description: Mapped[str | None] = mapped_column(Text) + # "window_open" (open in a new tab) | "iframe" (embed under a parent menu) + app_type: Mapped[str] = mapped_column(String(32)) + url: Mapped[str] = mapped_column(Text) + # List of route-param descriptors resolved at launch time by the frontend: + # {"key": str, "paramType": "login"|"theme", "source": str, + # "customValue": str, "themeValue": str} + route_params_json: Mapped[list] = mapped_column(PortableJSON(), default=list) + # iframe only: the parent sidebar menu id to mount under + # (e.g. "xk-center-task" / "pg-center-task" / "ty-center-task" / + # "comprehensive-management"). NULL for window_open apps. + mount_location: Mapped[str | None] = mapped_column(String(64), index=True) + icon: Mapped[str | None] = mapped_column(String(64)) + enabled: Mapped[bool] = mapped_column(Boolean, default=True, index=True) + sort_order: Mapped[int] = mapped_column(Integer, default=0) + created_by: Mapped[str | None] = mapped_column(String(64)) + updated_by: Mapped[str | None] = mapped_column(String(64)) + created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC)) + updated_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC)) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/light_apps/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/light_apps/sql.py new file mode 100644 index 00000000..8e01de41 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/light_apps/sql.py @@ -0,0 +1,135 @@ +"""SQLAlchemy-backed light-app registry store (global / shared).""" + +from __future__ import annotations + +import uuid +from datetime import UTC, datetime +from typing import Any + +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from deerflow.persistence.light_apps.base import DuplicateLightAppNameError, LightAppStore +from deerflow.persistence.light_apps.model import LightAppRow + +# Columns a caller may set/patch (everything except identity + timestamps). +_WRITABLE = { + "name", + "description", + "app_type", + "url", + "route_params", + "mount_location", + "icon", + "enabled", + "sort_order", + "updated_by", +} + + +def _to_dict(row: LightAppRow) -> dict[str, Any]: + data = row.to_dict() + for key in ("created_at", "updated_at"): + value = data.get(key) + if isinstance(value, datetime): + data[key] = value.isoformat() + data["route_params"] = data.pop("route_params_json", None) or [] + return data + + +def _column_for(key: str) -> str: + return "route_params_json" if key == "route_params" else key + + +class LightAppRepository(LightAppStore): + def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None: + self._sf = session_factory + + async def list_apps( + self, + *, + app_type: str | None = None, + mount_location: str | None = None, + enabled_only: bool = False, + ) -> list[dict[str, Any]]: + stmt = select(LightAppRow) + if app_type: + stmt = stmt.where(LightAppRow.app_type == app_type) + if mount_location: + stmt = stmt.where(LightAppRow.mount_location == mount_location) + if enabled_only: + stmt = stmt.where(LightAppRow.enabled.is_(True)) + stmt = stmt.order_by(LightAppRow.sort_order.asc(), LightAppRow.created_at.desc()) + async with self._sf() as session: + result = await session.execute(stmt) + return [_to_dict(row) for row in result.scalars()] + + async def get_app(self, app_id: str) -> dict[str, Any] | None: + async with self._sf() as session: + row = await session.get(LightAppRow, app_id) + return _to_dict(row) if row is not None else None + + async def _name_taken(self, session: AsyncSession, name: str, *, exclude_id: str | None = None) -> bool: + stmt = select(LightAppRow.app_id).where(LightAppRow.name == name) + if exclude_id is not None: + stmt = stmt.where(LightAppRow.app_id != exclude_id) + result = await session.execute(stmt.limit(1)) + return result.first() is not None + + async def create_app(self, data: dict[str, Any]) -> dict[str, Any]: + row = LightAppRow( + app_id=data.get("app_id") or uuid.uuid4().hex[:12], + name=data["name"], + description=data.get("description"), + app_type=data["app_type"], + url=data["url"], + route_params_json=data.get("route_params") or [], + mount_location=data.get("mount_location"), + icon=data.get("icon"), + enabled=data.get("enabled", True), + sort_order=data.get("sort_order", 0), + created_by=data.get("created_by"), + updated_by=data.get("created_by"), + ) + async with self._sf() as session: + if await self._name_taken(session, row.name): + raise DuplicateLightAppNameError(row.name) + session.add(row) + try: + await session.commit() + except IntegrityError as exc: + await session.rollback() + raise DuplicateLightAppNameError(row.name) from exc + await session.refresh(row) + return _to_dict(row) + + async def update_app(self, app_id: str, data: dict[str, Any]) -> dict[str, Any] | None: + async with self._sf() as session: + row = await session.get(LightAppRow, app_id) + if row is None: + return None + new_name = data.get("name") + if new_name is not None and new_name != row.name and await self._name_taken(session, new_name, exclude_id=app_id): + raise DuplicateLightAppNameError(new_name) + for key, value in data.items(): + if key not in _WRITABLE: + continue + setattr(row, _column_for(key), value) + row.updated_at = datetime.now(UTC) + try: + await session.commit() + except IntegrityError as exc: + await session.rollback() + raise DuplicateLightAppNameError(new_name or row.name) from exc + await session.refresh(row) + return _to_dict(row) + + async def delete_app(self, app_id: str) -> bool: + async with self._sf() as session: + row = await session.get(LightAppRow, app_id) + if row is None: + return False + await session.delete(row) + await session.commit() + return True diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py index 0a83dc4d..04aa993c 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/models/__init__.py @@ -36,6 +36,7 @@ from deerflow.knowledge.models import ( KnowledgeSourceRow, KnowledgeTagRow, ) +from deerflow.persistence.light_apps.model import LightAppRow from deerflow.persistence.llm_metrics.model import LlmCallMetricRow from deerflow.persistence.models.run_event import RunEventRow from deerflow.persistence.notifications.model import NotificationRow @@ -83,6 +84,7 @@ __all__ = [ "KnowledgeRelationRow", "KnowledgeSourceRow", "KnowledgeTagRow", + "LightAppRow", "LlmCallMetricRow", "NotificationRow", "ParallelAgentTaskRow",