feat: light app center and iframe refactor
This commit is contained in:
1 parent
728a4b1170
commit
9d73f27146
35 files changed
+1878
-246
No files matched your search
@@ -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/`
|
||||
|
||||
@@ -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<Set<string>>(() => new Set(defaultExpandedIds));
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -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(避免外部突变)
|
||||
|
||||
@@ -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: "综合管理",
|
||||
|
||||
@@ -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<string, LightApp[]>();
|
||||
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],
|
||||
);
|
||||
}
|
||||
@@ -27,6 +27,50 @@ export function getNetworkActorIframeUrl(): string {
|
||||
return `${base}${path.startsWith("/") ? path : `/${path}`}`;
|
||||
}
|
||||
|
||||
/** 读取并解析 localStorage 中的 userInfo(登录信息)。 */
|
||||
function readUserInfo(): Record<string, unknown> {
|
||||
try {
|
||||
const raw = localStorage.getItem("userInfo");
|
||||
if (!raw) return {};
|
||||
const parsed = JSON.parse(raw);
|
||||
return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {};
|
||||
} 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");
|
||||
|
||||
@@ -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 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => writingForm.setFormExpanded(false)}
|
||||
title="收起写作配置"
|
||||
>
|
||||
<PanelRightClose className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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<WritingFormValue>(
|
||||
() => ({
|
||||
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 <WritingFormContext.Provider value={value}>{children}</WritingFormContext.Provider>;
|
||||
|
||||
@@ -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<GroupImperativeHandle>(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 (
|
||||
<ResizablePanelGroup
|
||||
orientation="horizontal"
|
||||
groupRef={layoutRef}
|
||||
defaultLayout={SETUP_LAYOUT}
|
||||
className="h-full"
|
||||
>
|
||||
{/* 左侧:写作助手 Q&A */}
|
||||
<ResizablePanel
|
||||
id={SETUP_CHAT_ID}
|
||||
defaultSize={65}
|
||||
minSize={40}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="flex h-full">
|
||||
{/* 左侧:写作助手 Q&A —— 恒为第一个子节点,位置稳定不重挂 */}
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<WritingSetupChat />
|
||||
</ResizablePanel>
|
||||
</div>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
{/* 右侧:写作配置选项表单 */}
|
||||
<ResizablePanel
|
||||
id={SETUP_FORM_ID}
|
||||
defaultSize={35}
|
||||
minSize={20}
|
||||
className="overflow-hidden flex flex-col"
|
||||
{/* 右侧:写作配置表单整块。容器持久存在,宽度随 formExpanded 平滑过渡(展开/收起
|
||||
都有动画);收起时只剩一条竖向「展开」栏,收起按钮则在表单头部右上角。 */}
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex min-h-0 shrink-0 flex-col overflow-hidden border-l transition-[width,min-width] duration-300 ease-in-out',
|
||||
formExpanded ? 'w-[36%] min-w-[340px] max-w-[600px]' : 'w-10 min-w-10',
|
||||
)}
|
||||
>
|
||||
<WritingFormPanel hideAdvancedFields={hideAdvancedFields} initialNotebookId={initialNotebookId} initialDocumentIds={initialDocumentIds} studioToken={studioToken} />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
{formExpanded ? (
|
||||
<WritingFormPanel hideAdvancedFields={hideAdvancedFields} initialNotebookId={initialNotebookId} initialDocumentIds={initialDocumentIds} studioToken={studioToken} />
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormExpanded(true)}
|
||||
title="展开写作配置"
|
||||
className="flex h-full w-full flex-col items-center justify-center gap-3 bg-muted/30 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
<span className="[writing-mode:vertical-rl] text-xs tracking-widest">写作配置</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <div className={centerClass}>加载中…</div>;
|
||||
}
|
||||
if (error || !app) {
|
||||
return <div className={centerClass}>应用不存在或已被删除</div>;
|
||||
}
|
||||
if (app.appType !== "iframe") {
|
||||
return <div className={centerClass}>该应用不是 iframe 嵌套类型</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<iframe src={src} title={app.name} className="w-full flex-1 border-0" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* 应用列表页 —— 以卡片网格展示「新页跳转(window.open)」类型的轻应用。
|
||||
*
|
||||
* 支持搜索 + 分页。点击卡片用 buildLightAppUrl 构造最终地址(追加解析后的路由参数)
|
||||
* 并 window.open 新开页跳转。仅展示已启用的应用;iframe 类型不在此处(它们注入侧边栏)。
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ExternalLink, Search } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Pagination } from "@/components/ui/pagination";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { useLightApps, type LightApp } from "@/strategy-components/api/light-apps";
|
||||
import { buildLightAppUrl } from "@/strategy-components/lib/light-app";
|
||||
|
||||
const PAGE_SIZE = 12;
|
||||
|
||||
export default function LightAppListPage() {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === "dark";
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const { data: apps = [], isLoading } = useLightApps({
|
||||
appType: "window_open",
|
||||
enabledOnly: true,
|
||||
search,
|
||||
});
|
||||
|
||||
// 搜索变化时回到第一页。
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [search]);
|
||||
|
||||
const pageApps = useMemo(
|
||||
() => apps.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
|
||||
[apps, page],
|
||||
);
|
||||
|
||||
const openApp = (app: LightApp) => {
|
||||
const url = buildLightAppUrl(app);
|
||||
if (!url) {
|
||||
toast.error("该应用地址为空,无法打开");
|
||||
return;
|
||||
}
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
const shellClass = isDark
|
||||
? "h-full overflow-y-auto bg-[linear-gradient(180deg,#081322_0%,#0d1d33_42%,#0a1628_100%)] text-slate-100"
|
||||
: "h-full overflow-y-auto bg-[linear-gradient(180deg,#fffdfd_0%,#fff7f7_42%,#fafafa_100%)] text-[#222222]";
|
||||
const cardClass = isDark
|
||||
? "group cursor-pointer rounded-2xl border border-[rgba(67,89,124,0.46)] bg-[rgba(12,29,54,0.94)] p-4 transition-all hover:-translate-y-0.5 hover:border-[rgba(77,141,255,0.6)] hover:shadow-[0_18px_38px_rgba(3,10,22,0.35)]"
|
||||
: "group cursor-pointer rounded-2xl border border-[#E8E8E8] bg-white p-4 transition-all hover:-translate-y-0.5 hover:border-[#FFA39E] hover:shadow-[0_16px_32px_rgba(245,34,45,0.1)]";
|
||||
const mutedText = isDark ? "text-slate-500" : "text-[#A6A6A6]";
|
||||
const iconWrap = isDark
|
||||
? "bg-[rgba(77,141,255,0.16)] text-[#9CCBFF]"
|
||||
: "bg-[#FFF1F0] text-[#CF1322]";
|
||||
|
||||
return (
|
||||
<div className={shellClass}>
|
||||
<div className="mx-auto flex min-h-full max-w-[1280px] flex-col gap-5 px-6 py-6 lg:px-8">
|
||||
<div className="space-y-1">
|
||||
<h1 className={`text-[26px] font-semibold tracking-[0.02em] ${isDark ? "text-slate-50" : "text-[#222222]"}`}>
|
||||
应用列表
|
||||
</h1>
|
||||
<p className={`text-sm ${isDark ? "text-slate-300/80" : "text-slate-500"}`}>
|
||||
点击卡片在新标签页打开应用。地址会自动附带配置的路由参数(登录态 / 主题)。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative max-w-sm">
|
||||
<Search className={`pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 ${mutedText}`} />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="搜索应用名称 / 描述"
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{apps.length === 0 ? (
|
||||
<div className={`rounded-3xl border py-20 text-center ${isDark ? "border-[rgba(67,89,124,0.4)] text-slate-500" : "border-[#F0F0F0] text-[#A6A6A6]"}`}>
|
||||
{isLoading ? "加载中…" : "暂无可用应用"}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{pageApps.map((app) => (
|
||||
<button key={app.appId} type="button" onClick={() => openApp(app)} className={`${cardClass} text-left`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className={`flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl ${iconWrap}`}>
|
||||
<ExternalLink className="h-5 w-5" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className={`truncate text-[15px] font-semibold ${isDark ? "text-white" : "text-[#222222]"}`}>
|
||||
{app.name}
|
||||
</div>
|
||||
<div className={`mt-1 line-clamp-2 text-xs leading-relaxed ${isDark ? "text-slate-400" : "text-slate-500"}`}>
|
||||
{app.description || app.url}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Pagination page={page} pageSize={PAGE_SIZE} total={apps.length} onPageChange={setPage} className="pt-2" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
/**
|
||||
* 应用管理页 —— 注册 / 编辑 / 删除轻应用(全局共享,写操作仅 admin)。
|
||||
*
|
||||
* 两种类型:
|
||||
* - window_open:填地址 + 路由参数,列表页以卡片展示、点击新开页跳转。
|
||||
* - iframe:填地址 + 路由参数 + 选择挂载菜单,注册后动态注入侧边栏对应父菜单下。
|
||||
*
|
||||
* 路由参数构造器:参数名(key) + 类型(登录/主题)。登录参数右侧选 accessToken/yUserId/
|
||||
* userName/其他(自填值),运行时从 localStorage.userInfo 解析;主题参数填 dark-blue/red/blue。
|
||||
*/
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
ExternalLink,
|
||||
Layers,
|
||||
Pencil,
|
||||
Plus,
|
||||
Search,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import {
|
||||
useLightAppActions,
|
||||
useLightApps,
|
||||
type LightApp,
|
||||
type LightAppRouteParam,
|
||||
type LightAppType,
|
||||
} from "@/strategy-components/api/light-apps";
|
||||
import {
|
||||
buildLightAppUrl,
|
||||
LOGIN_SOURCE_OPTIONS,
|
||||
MOUNT_OPTIONS,
|
||||
mountLabel,
|
||||
THEME_EXAMPLE_OPTIONS,
|
||||
} from "@/strategy-components/lib/light-app";
|
||||
|
||||
interface FormState {
|
||||
name: string;
|
||||
description: string;
|
||||
appType: LightAppType;
|
||||
url: string;
|
||||
mountLocation: string;
|
||||
enabled: boolean;
|
||||
routeParams: LightAppRouteParam[];
|
||||
}
|
||||
|
||||
const emptyForm = (): FormState => ({
|
||||
name: "",
|
||||
description: "",
|
||||
appType: "window_open",
|
||||
url: "",
|
||||
mountLocation: "",
|
||||
enabled: true,
|
||||
routeParams: [],
|
||||
});
|
||||
|
||||
const newParam = (): LightAppRouteParam => ({
|
||||
key: "",
|
||||
paramType: "login",
|
||||
source: "accessToken",
|
||||
customValue: "",
|
||||
themeValue: "dark-blue",
|
||||
});
|
||||
|
||||
function formFromApp(app: LightApp): FormState {
|
||||
return {
|
||||
name: app.name,
|
||||
description: app.description ?? "",
|
||||
appType: app.appType,
|
||||
url: app.url,
|
||||
mountLocation: app.mountLocation ?? "",
|
||||
enabled: app.enabled,
|
||||
routeParams: app.routeParams.map((p) => ({
|
||||
key: p.key,
|
||||
paramType: p.paramType,
|
||||
source: p.source ?? "accessToken",
|
||||
customValue: p.customValue ?? "",
|
||||
themeValue: p.themeValue ?? "dark-blue",
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export default function LightAppManagementPage() {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === "dark";
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const { data: apps = [], isLoading } = useLightApps({ search });
|
||||
const actions = useLightAppActions();
|
||||
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<LightApp | null>(null);
|
||||
const [form, setForm] = useState<FormState>(emptyForm);
|
||||
|
||||
const saving = actions.create.isPending || actions.update.isPending;
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setForm(emptyForm());
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (app: LightApp) => {
|
||||
setEditing(app);
|
||||
setForm(formFromApp(app));
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const patchForm = (patch: Partial<FormState>) => setForm((f) => ({ ...f, ...patch }));
|
||||
|
||||
const patchParam = (idx: number, patch: Partial<LightAppRouteParam>) =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
routeParams: f.routeParams.map((p, i) => (i === idx ? { ...p, ...patch } : p)),
|
||||
}));
|
||||
|
||||
const addParam = () => patchForm({ routeParams: [...form.routeParams, newParam()] });
|
||||
const removeParam = (idx: number) =>
|
||||
patchForm({ routeParams: form.routeParams.filter((_, i) => i !== idx) });
|
||||
|
||||
const previewUrl = useMemo(() => {
|
||||
if (!form.url) return "";
|
||||
try {
|
||||
return buildLightAppUrl({ url: form.url, routeParams: cleanParams(form.routeParams) });
|
||||
} catch {
|
||||
return form.url;
|
||||
}
|
||||
}, [form.url, form.routeParams]);
|
||||
|
||||
const handleSave = async () => {
|
||||
const name = form.name.trim();
|
||||
const url = form.url.trim();
|
||||
if (!name) return toast.error("请填写应用名称");
|
||||
if (!url) return toast.error("请填写应用地址");
|
||||
if (form.appType === "iframe" && !form.mountLocation) return toast.error("iframe 类型请选择挂载菜单");
|
||||
for (const p of form.routeParams) {
|
||||
if (!p.key.trim()) return toast.error("路由参数的「参数名」不能为空");
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name,
|
||||
description: form.description.trim() || null,
|
||||
appType: form.appType,
|
||||
url,
|
||||
routeParams: cleanParams(form.routeParams),
|
||||
mountLocation: form.appType === "iframe" ? form.mountLocation : null,
|
||||
enabled: form.enabled,
|
||||
};
|
||||
|
||||
try {
|
||||
if (editing) {
|
||||
await actions.update.mutateAsync({ appId: editing.appId, patch: payload });
|
||||
toast.success(`已更新「${name}」`);
|
||||
} else {
|
||||
await actions.create.mutateAsync(payload);
|
||||
toast.success(`已注册「${name}」`);
|
||||
}
|
||||
setDialogOpen(false);
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message || "保存失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (app: LightApp) => {
|
||||
if (!window.confirm(`确定删除应用「${app.name}」吗?`)) return;
|
||||
try {
|
||||
await actions.remove.mutateAsync(app.appId);
|
||||
toast.success(`已删除「${app.name}」`);
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message || "删除失败");
|
||||
}
|
||||
};
|
||||
|
||||
// ── theme classes(与配置管理页一致) ──────────────────────────────────
|
||||
const shellClass = isDark
|
||||
? "h-full overflow-y-auto bg-[linear-gradient(180deg,#081322_0%,#0d1d33_42%,#0a1628_100%)] text-slate-100"
|
||||
: "h-full overflow-y-auto bg-[linear-gradient(180deg,#fffdfd_0%,#fff7f7_42%,#fafafa_100%)] text-[#222222]";
|
||||
const cardClass = isDark
|
||||
? "border border-[rgba(67,89,124,0.46)] bg-[rgba(12,29,54,0.94)] shadow-[0_16px_36px_rgba(3,10,22,0.2)]"
|
||||
: "border border-[#E8E8E8] bg-white shadow-[0_14px_30px_rgba(245,34,45,0.06)]";
|
||||
const headRowClass = isDark ? "text-slate-400" : "text-[#8C8C8C]";
|
||||
const rowBorder = isDark ? "border-[rgba(67,89,124,0.32)]" : "border-[#F0F0F0]";
|
||||
const mutedText = isDark ? "text-slate-500" : "text-[#A6A6A6]";
|
||||
|
||||
const typeBadge = (t: LightAppType) =>
|
||||
t === "iframe"
|
||||
? isDark
|
||||
? "bg-[rgba(168,85,247,0.16)] text-[#D6B4FF]"
|
||||
: "bg-[#F9F0FF] text-[#722ED1]"
|
||||
: isDark
|
||||
? "bg-[rgba(77,141,255,0.16)] text-[#9CCBFF]"
|
||||
: "bg-[#FFF1F0] text-[#CF1322]";
|
||||
|
||||
return (
|
||||
<div className={shellClass}>
|
||||
<div className="mx-auto flex min-h-full max-w-[1280px] flex-col gap-5 px-6 py-6 lg:px-8">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<h1 className={`text-[26px] font-semibold tracking-[0.02em] ${isDark ? "text-slate-50" : "text-[#222222]"}`}>
|
||||
应用管理
|
||||
</h1>
|
||||
<p className={`text-sm ${isDark ? "text-slate-300/80" : "text-slate-500"}`}>
|
||||
注册轻应用。window.open 类型在「应用列表」以卡片展示;iframe 类型挂载到所选菜单下并注入侧边栏。
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={openCreate} className="gap-1.5">
|
||||
<Plus className="h-4 w-4" />
|
||||
注册应用
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="relative max-w-sm">
|
||||
<Search className={`pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 ${mutedText}`} />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="搜索应用名称 / 描述"
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`overflow-hidden rounded-3xl ${cardClass}`}>
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className={`text-left text-xs uppercase tracking-widest ${headRowClass}`}>
|
||||
<th className="px-6 py-4 font-semibold">名称</th>
|
||||
<th className="px-6 py-4 font-semibold">类型</th>
|
||||
<th className="px-6 py-4 font-semibold">地址 / 挂载</th>
|
||||
<th className="px-6 py-4 font-semibold">参数</th>
|
||||
<th className="px-6 py-4 font-semibold">启用</th>
|
||||
<th className="px-6 py-4 text-right font-semibold">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{apps.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className={`px-6 py-12 text-center ${mutedText}`}>
|
||||
{isLoading ? "加载中…" : "暂无应用,点击右上角「注册应用」开始"}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
apps.map((app) => (
|
||||
<tr key={app.appId} className={`border-t ${rowBorder}`}>
|
||||
<td className="px-6 py-4 align-middle">
|
||||
<div className={`text-[15px] font-semibold ${isDark ? "text-white" : "text-[#222222]"}`}>
|
||||
{app.name}
|
||||
</div>
|
||||
{app.description && (
|
||||
<div className={`mt-0.5 line-clamp-1 text-xs ${mutedText}`}>{app.description}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 align-middle">
|
||||
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium ${typeBadge(app.appType)}`}>
|
||||
{app.appType === "iframe" ? <Layers className="h-3 w-3" /> : <ExternalLink className="h-3 w-3" />}
|
||||
{app.appType === "iframe" ? "iframe 嵌套" : "新页跳转"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 align-middle">
|
||||
<div className="max-w-[320px] truncate font-mono text-xs">{app.url}</div>
|
||||
{app.appType === "iframe" && app.mountLocation && (
|
||||
<div className={`mt-0.5 text-xs ${mutedText}`}>挂载:{mountLabel(app.mountLocation)}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 align-middle">
|
||||
<span className={mutedText}>{app.routeParams.length} 个</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 align-middle">
|
||||
<span className={app.enabled ? "text-emerald-500" : mutedText}>
|
||||
{app.enabled ? "已启用" : "已停用"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right align-middle">
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" className="gap-1" onClick={() => openEdit(app)}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
编辑
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="gap-1 text-red-500 hover:text-red-600" onClick={() => handleDelete(app)}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-h-[88vh] overflow-y-auto sm:max-w-[640px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editing ? "编辑应用" : "注册应用"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
{/* 类型 */}
|
||||
<Field label="应用类型">
|
||||
<Select value={form.appType} onValueChange={(v) => patchForm({ appType: v as LightAppType })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="window_open">新页跳转(window.open)</SelectItem>
|
||||
<SelectItem value="iframe">iframe 嵌套(挂载到菜单)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label="应用名称">
|
||||
<Input value={form.name} onChange={(e) => patchForm({ name: e.target.value })} placeholder="如:舆情看板" />
|
||||
</Field>
|
||||
|
||||
<Field label="应用地址">
|
||||
<Input
|
||||
value={form.url}
|
||||
onChange={(e) => patchForm({ url: e.target.value })}
|
||||
placeholder="https://example.com/page 或 https://x#/route"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{form.appType === "iframe" && (
|
||||
<Field label="挂载菜单">
|
||||
<Select value={form.mountLocation || undefined} onValueChange={(v) => patchForm({ mountLocation: v })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择挂载到哪个菜单下" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MOUNT_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field label="应用描述(可选)">
|
||||
<Textarea
|
||||
value={form.description}
|
||||
onChange={(e) => patchForm({ description: e.target.value })}
|
||||
placeholder="简要说明该应用的用途"
|
||||
rows={2}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{/* 路由参数构造器 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">路由参数</span>
|
||||
<Button variant="outline" size="sm" className="gap-1" onClick={addParam}>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
添加路由参数
|
||||
</Button>
|
||||
</div>
|
||||
{form.routeParams.length === 0 && (
|
||||
<p className={`text-xs ${mutedText}`}>暂无参数。登录参数运行时从 userInfo 取值,主题参数用固定值。</p>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{form.routeParams.map((p, idx) => (
|
||||
<ParamRow
|
||||
key={idx}
|
||||
param={p}
|
||||
isDark={isDark}
|
||||
onChange={(patch) => patchParam(idx, patch)}
|
||||
onRemove={() => removeParam(idx)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{previewUrl && (
|
||||
<div className={`rounded-lg border px-3 py-2 ${isDark ? "border-[rgba(67,89,124,0.4)] bg-[rgba(8,19,34,0.6)]" : "border-[#F0F0F0] bg-[#FAFAFA]"}`}>
|
||||
<div className={`text-xs ${mutedText}`}>访问预览(登录参数取自当前 userInfo)</div>
|
||||
<div className="mt-1 break-all font-mono text-xs">{previewUrl}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">启用</span>
|
||||
<Switch checked={form.enabled} onCheckedChange={(v) => patchForm({ enabled: v })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={saving}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? "保存中…" : editing ? "保存" : "注册"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 去掉与当前参数类型无关的字段,保持载荷干净。 */
|
||||
function cleanParams(params: LightAppRouteParam[]): LightAppRouteParam[] {
|
||||
return params
|
||||
.filter((p) => p.key.trim())
|
||||
.map((p) => {
|
||||
const base: LightAppRouteParam = { key: p.key.trim(), paramType: p.paramType };
|
||||
if (p.paramType === "login") {
|
||||
base.source = p.source || "accessToken";
|
||||
if (base.source === "other") base.customValue = p.customValue ?? "";
|
||||
} else {
|
||||
base.themeValue = p.themeValue ?? "";
|
||||
}
|
||||
return base;
|
||||
});
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function ParamRow({
|
||||
param,
|
||||
isDark,
|
||||
onChange,
|
||||
onRemove,
|
||||
}: {
|
||||
param: LightAppRouteParam;
|
||||
isDark: boolean;
|
||||
onChange: (patch: Partial<LightAppRouteParam>) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const rowClass = isDark
|
||||
? "border border-[rgba(67,89,124,0.4)] bg-[rgba(8,19,34,0.5)]"
|
||||
: "border border-[#F0F0F0] bg-[#FAFAFA]";
|
||||
return (
|
||||
<div className={`rounded-lg p-2.5 ${rowClass}`}>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
value={param.key}
|
||||
onChange={(e) => onChange({ key: e.target.value })}
|
||||
placeholder="参数名 key"
|
||||
className="h-8 w-[140px]"
|
||||
/>
|
||||
<Select value={param.paramType} onValueChange={(v) => onChange({ paramType: v as "login" | "theme" })}>
|
||||
<SelectTrigger className="h-8 w-[120px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="login">登录参数</SelectItem>
|
||||
<SelectItem value="theme">主题参数</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{param.paramType === "login" ? (
|
||||
<>
|
||||
<Select value={param.source || "accessToken"} onValueChange={(v) => onChange({ source: v })}>
|
||||
<SelectTrigger className="h-8 w-[150px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LOGIN_SOURCE_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{param.source === "other" && (
|
||||
<Input
|
||||
value={param.customValue ?? ""}
|
||||
onChange={(e) => onChange({ customValue: e.target.value })}
|
||||
placeholder="自定义值"
|
||||
className="h-8 w-[150px]"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Input
|
||||
value={param.themeValue ?? ""}
|
||||
onChange={(e) => onChange({ themeValue: e.target.value })}
|
||||
placeholder="主题值,如 dark-blue"
|
||||
className="h-8 w-[160px]"
|
||||
/>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="ml-auto inline-flex h-7 w-7 items-center justify-center rounded-md text-red-500 hover:bg-red-500/10"
|
||||
title="删除该参数"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{param.paramType === "theme" && (
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-1.5 pl-1">
|
||||
<span className="text-[11px] text-muted-foreground">示例(点击填入,可再修改):</span>
|
||||
{THEME_EXAMPLE_OPTIONS.map((o) => (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
onClick={() => onChange({ themeValue: o.value })}
|
||||
className={`rounded px-1.5 py-0.5 text-[11px] ${
|
||||
param.themeValue === o.value
|
||||
? "bg-primary/15 text-primary"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted/70"
|
||||
}`}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,14 +5,12 @@ import { SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { RouteBaseProvider } from "@/core/route-base";
|
||||
import GeneralQA from "@/strategy-components/components/chat-qa/GeneralQA";
|
||||
import FanganTyPage from "@/strategy-components/components/task-components/FanganTy";
|
||||
import SentimentAnalyzes from "@/strategy-components/components/task-components/SentimentAnalyzes";
|
||||
import TaskManagement from "@/strategy-components/components/task-components/TaskManagement";
|
||||
import TaskPgFrom from "@/strategy-components/components/task-components/TaskPgFrom";
|
||||
import TaskTyFrom from "@/strategy-components/components/task-components/TaskTyFrom";
|
||||
import TaskXkFrom from "@/strategy-components/components/task-components/TaskXkFrom";
|
||||
import TaskAnalyzes from "@/strategy-components/components/task-components/TaskAnalyzes";
|
||||
import XingdongTyPage from "@/strategy-components/components/task-components/XingdongTy";
|
||||
import XXTyPage from "@/strategy-components/components/task-components/XXTy";
|
||||
import { ActionAnalyzes } from "@/strategy-components/components/task-components/ActionAnalyzes";
|
||||
import KnowledgeBaseResourceManagement from "@/strategy-components/components/resource-management/KnowledgeBaseResourceManagement";
|
||||
import NetworkActorResourceManagement from "@/strategy-components/components/resource-management/NetworkActorResourceManagement";
|
||||
@@ -27,6 +25,9 @@ import ChatPage from "./ChatPage";
|
||||
import ChatsPage from "./ChatsPage";
|
||||
import AdminUserLeaderboardPage from "./AdminUserLeaderboardPage";
|
||||
import AdminUserLeaderboardSettingsPage from "./AdminUserLeaderboardSettingsPage";
|
||||
import LightAppIframeView from "./LightAppIframeView";
|
||||
import LightAppListPage from "./LightAppListPage";
|
||||
import LightAppManagementPage from "./LightAppManagementPage";
|
||||
import LightAppNewNotePage from "./LightAppNewNotePage";
|
||||
import LightAppNoteListPage from "./LightAppNoteListPage";
|
||||
import LightAppNoteQAPage from "./LightAppNoteQAPage";
|
||||
@@ -125,9 +126,9 @@ export default function StrategyRoutes() {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="mnty-task-mechanism" element={<XingdongTyPage />} />
|
||||
<Route path="mnty-task-plan" element={<FanganTyPage />} />
|
||||
<Route path="mnty-task-zsty" element={<XXTyPage />} />
|
||||
<Route path="mnty-task-mechanism" element={<TaskTyFrom defaultPage="iframeXdty" title="XDTY任务" />} />
|
||||
<Route path="mnty-task-plan" element={<TaskTyFrom defaultPage="iframeFaty" title="方案TY任务" />} />
|
||||
<Route path="mnty-task-zsty" element={<TaskTyFrom defaultPage="iframeSysystem" title="XXTY任务" />} />
|
||||
</Route>
|
||||
|
||||
<Route
|
||||
@@ -143,6 +144,9 @@ export default function StrategyRoutes() {
|
||||
element={<TaskXkFrom gopath="#/pageIndie/pageZyjc/pageZyzl" />}
|
||||
/>
|
||||
|
||||
<Route path="light-app/management" element={<LightAppManagementPage />} />
|
||||
<Route path="light-app/apps" element={<LightAppListPage />} />
|
||||
<Route path="light-app/view/:appId" element={<LightAppIframeView />} />
|
||||
<Route path="light-app/new-note" element={<LightAppNewNotePage />} />
|
||||
<Route path="light-app/notes" element={<LightAppNoteListPage />} />
|
||||
<Route path="light-app/note-qa" element={<LightAppNoteQAPage />} />
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* 轻应用中心(应用管理 / 应用列表)API 客户端 + React Query hooks。
|
||||
*
|
||||
* 后端:`/api/light-apps`(见 app/gateway/routers/light_apps.py)。
|
||||
* 全局共享、无用户隔离;读对所有登录用户开放,写仅 admin。
|
||||
* snake_case ⇄ camelCase 在此边界收敛;路由参数项本身用 camelCase
|
||||
* (key/paramType/source/customValue/themeValue),与后端 RouteParam 字段一致,原样透传。
|
||||
*/
|
||||
|
||||
import {
|
||||
keepPreviousData,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
export type LightAppType = "window_open" | "iframe";
|
||||
export type RouteParamType = "login" | "theme";
|
||||
|
||||
/** 路由参数项。登录参数运行时从 localStorage.userInfo 解析;主题参数用 themeValue。 */
|
||||
export interface LightAppRouteParam {
|
||||
key: string;
|
||||
paramType: RouteParamType;
|
||||
/** 登录参数:accessToken / yUserId / userName / "other"。 */
|
||||
source?: string | null;
|
||||
/** 登录参数且 source==="other" 时的字面值。 */
|
||||
customValue?: string | null;
|
||||
/** 主题参数的值(dark-blue / red / blue,可改)。 */
|
||||
themeValue?: string | null;
|
||||
}
|
||||
|
||||
export interface LightApp {
|
||||
appId: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
appType: LightAppType;
|
||||
url: string;
|
||||
routeParams: LightAppRouteParam[];
|
||||
/** iframe 挂载的父菜单 id(window_open 为 null)。 */
|
||||
mountLocation: string | null;
|
||||
icon: string | null;
|
||||
enabled: boolean;
|
||||
sortOrder: number;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface LightAppInput {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
appType: LightAppType;
|
||||
url: string;
|
||||
routeParams: LightAppRouteParam[];
|
||||
mountLocation?: string | null;
|
||||
icon?: string | null;
|
||||
enabled?: boolean;
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export interface ListLightAppsParams {
|
||||
appType?: LightAppType;
|
||||
mountLocation?: string;
|
||||
enabledOnly?: boolean;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
const BASE = () => `${getBackendBaseURL()}/api/light-apps`;
|
||||
|
||||
interface ServerRouteParam {
|
||||
key: string;
|
||||
paramType: RouteParamType;
|
||||
source?: string | null;
|
||||
customValue?: string | null;
|
||||
themeValue?: string | null;
|
||||
}
|
||||
|
||||
interface ServerApp {
|
||||
app_id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
app_type: LightAppType;
|
||||
url: string;
|
||||
route_params: ServerRouteParam[];
|
||||
mount_location: string | null;
|
||||
icon: string | null;
|
||||
enabled: boolean;
|
||||
sort_order: number;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
function toApp(s: ServerApp): LightApp {
|
||||
return {
|
||||
appId: s.app_id,
|
||||
name: s.name,
|
||||
description: s.description ?? null,
|
||||
appType: s.app_type,
|
||||
url: s.url,
|
||||
routeParams: (s.route_params ?? []).map((p) => ({
|
||||
key: p.key,
|
||||
paramType: p.paramType,
|
||||
source: p.source ?? null,
|
||||
customValue: p.customValue ?? null,
|
||||
themeValue: p.themeValue ?? null,
|
||||
})),
|
||||
mountLocation: s.mount_location ?? null,
|
||||
icon: s.icon ?? null,
|
||||
enabled: s.enabled ?? true,
|
||||
sortOrder: s.sort_order ?? 0,
|
||||
createdAt: s.created_at ?? null,
|
||||
updatedAt: s.updated_at ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function toBody(input: Partial<LightAppInput>): Record<string, unknown> {
|
||||
const body: Record<string, unknown> = {};
|
||||
if (input.name !== undefined) body.name = input.name;
|
||||
if (input.description !== undefined) body.description = input.description;
|
||||
if (input.appType !== undefined) body.app_type = input.appType;
|
||||
if (input.url !== undefined) body.url = input.url;
|
||||
if (input.routeParams !== undefined) body.route_params = input.routeParams;
|
||||
if (input.mountLocation !== undefined) body.mount_location = input.mountLocation;
|
||||
if (input.icon !== undefined) body.icon = input.icon;
|
||||
if (input.enabled !== undefined) body.enabled = input.enabled;
|
||||
if (input.sortOrder !== undefined) body.sort_order = input.sortOrder;
|
||||
return body;
|
||||
}
|
||||
|
||||
async function readJson<T>(res: Response, ctx: string): Promise<T> {
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null as unknown);
|
||||
const detail =
|
||||
data && typeof data === "object" && "detail" in data
|
||||
? String((data as { detail: unknown }).detail)
|
||||
: `${ctx} failed (${res.status})`;
|
||||
throw new Error(detail);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
export async function listLightApps(params: ListLightAppsParams = {}): Promise<LightApp[]> {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.appType) qs.set("app_type", params.appType);
|
||||
if (params.mountLocation) qs.set("mount_location", params.mountLocation);
|
||||
if (params.enabledOnly) qs.set("enabled_only", "true");
|
||||
if (params.search) qs.set("search", params.search);
|
||||
const query = qs.toString();
|
||||
const res = await apiFetch(`${BASE()}${query ? `?${query}` : ""}`, { method: "GET" });
|
||||
const data = await readJson<{ apps: ServerApp[] }>(res, "list light apps");
|
||||
return (data.apps ?? []).map(toApp);
|
||||
}
|
||||
|
||||
export async function getLightApp(appId: string): Promise<LightApp> {
|
||||
const res = await apiFetch(`${BASE()}/${encodeURIComponent(appId)}`, { method: "GET" });
|
||||
return toApp(await readJson<ServerApp>(res, "get light app"));
|
||||
}
|
||||
|
||||
export async function createLightApp(input: LightAppInput): Promise<LightApp> {
|
||||
const res = await apiFetch(BASE(), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(toBody(input)),
|
||||
});
|
||||
return toApp(await readJson<ServerApp>(res, "create light app"));
|
||||
}
|
||||
|
||||
export async function updateLightApp(
|
||||
appId: string,
|
||||
patch: Partial<LightAppInput>,
|
||||
): Promise<LightApp> {
|
||||
const res = await apiFetch(`${BASE()}/${encodeURIComponent(appId)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(toBody(patch)),
|
||||
});
|
||||
return toApp(await readJson<ServerApp>(res, "update light app"));
|
||||
}
|
||||
|
||||
export async function deleteLightApp(appId: string): Promise<void> {
|
||||
const res = await apiFetch(`${BASE()}/${encodeURIComponent(appId)}`, { method: "DELETE" });
|
||||
await readJson<{ success: boolean }>(res, "delete light app");
|
||||
}
|
||||
|
||||
// ── React Query hooks(管理页 / 列表页 / 侧边栏共用缓存) ───────────────────
|
||||
|
||||
const LIGHT_APPS_KEY = "light-apps";
|
||||
|
||||
export function useLightApps(params: ListLightAppsParams = {}) {
|
||||
return useQuery({
|
||||
queryKey: [
|
||||
LIGHT_APPS_KEY,
|
||||
"list",
|
||||
params.appType ?? "",
|
||||
params.mountLocation ?? "",
|
||||
params.enabledOnly ?? false,
|
||||
params.search ?? "",
|
||||
],
|
||||
queryFn: () => listLightApps(params),
|
||||
placeholderData: keepPreviousData,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useLightApp(appId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: [LIGHT_APPS_KEY, "detail", appId ?? ""],
|
||||
queryFn: () => getLightApp(appId as string),
|
||||
enabled: !!appId,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useLightAppActions() {
|
||||
const queryClient = useQueryClient();
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: [LIGHT_APPS_KEY] });
|
||||
return {
|
||||
create: useMutation({ mutationFn: createLightApp, onSuccess: invalidate }),
|
||||
update: useMutation({
|
||||
mutationFn: ({ appId, patch }: { appId: string; patch: Partial<LightAppInput> }) =>
|
||||
updateLightApp(appId, patch),
|
||||
onSuccess: invalidate,
|
||||
}),
|
||||
remove: useMutation({ mutationFn: deleteLightApp, onSuccess: invalidate }),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* 统一的外部 iframe 嵌套页渲染组件。
|
||||
*
|
||||
* XK / PG / TY 任务、资源管理等所有「把外部系统嵌进来」的页面共用此组件,
|
||||
* 只负责把已构造好的 `src` 稳健地渲染出来。URL 构造逻辑收敛在
|
||||
* `@/core/strategy/external-links`,各业务页只做薄封装。
|
||||
*
|
||||
* 关键点 —— `key={src}`:当地址变化(如 XK 在菜单间切换 gopath,只改 hash 片段)时,
|
||||
* 强制 React 重挂载一个全新的 iframe,触发完整加载,避免「只改 hash 时浏览器不重载、
|
||||
* 内嵌 SPA 来不及切换路由」导致的页面没变化问题。
|
||||
*/
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import "../task-components/css/iframe.css";
|
||||
|
||||
interface ExternalIframePageProps {
|
||||
/** 已构造好的完整地址;为空时展示加载占位(异步取址场景)。 */
|
||||
src: string;
|
||||
title?: string;
|
||||
loadingText?: string;
|
||||
}
|
||||
|
||||
export default function ExternalIframePage({
|
||||
src,
|
||||
title,
|
||||
loadingText = "加载中…",
|
||||
}: ExternalIframePageProps) {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === "dark";
|
||||
|
||||
if (!src) {
|
||||
return (
|
||||
<div className={`flex h-full w-full items-center justify-center text-sm ${isDark ? "text-slate-400" : "text-slate-500"}`}>
|
||||
{loadingText}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="iframe-container">
|
||||
<iframe key={src} src={src} title={title} className="full-size-iframe" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+3
-6
@@ -5,11 +5,12 @@ import {
|
||||
getKnowledgeIframeSkin,
|
||||
} from "@/core/strategy/external-links";
|
||||
import { requestCrudService } from "@/service/request-axios-service";
|
||||
import "@/strategy-components/components/task-components/css/strategy-data.css";
|
||||
import ExternalIframePage from "../common/ExternalIframePage";
|
||||
|
||||
const service = requestCrudService;
|
||||
|
||||
const KnowledgeBaseResourceManagement: React.FC = () => {
|
||||
// 知识库地址需异步调接口换取(区别于其它 env 直拼的 iframe 页)。
|
||||
const [iframeUrl, setIframeUrl] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
@@ -40,11 +41,7 @@ const KnowledgeBaseResourceManagement: React.FC = () => {
|
||||
void initIframeUrl();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="strategy-data iframe-container">
|
||||
<iframe src={iframeUrl} className="full-size-iframe" title="知识库资源管理" />
|
||||
</div>
|
||||
);
|
||||
return <ExternalIframePage src={iframeUrl} title="知识库资源管理" />;
|
||||
};
|
||||
|
||||
export default KnowledgeBaseResourceManagement;
|
||||
+4
-13
@@ -1,20 +1,11 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useMemo } from "react";
|
||||
|
||||
import { getNetworkActorIframeUrl } from "@/core/strategy/external-links";
|
||||
import "@/strategy-components/components/task-components/css/strategy-data.css";
|
||||
import ExternalIframePage from "../common/ExternalIframePage";
|
||||
|
||||
const NetworkActorResourceManagement: React.FC = () => {
|
||||
const [iframeUrl, setIframeUrl] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setIframeUrl(getNetworkActorIframeUrl());
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="strategy-data iframe-container">
|
||||
<iframe src={iframeUrl} className="full-size-iframe" title="网络xwt资源管理" />
|
||||
</div>
|
||||
);
|
||||
const src = useMemo(() => getNetworkActorIframeUrl(), []);
|
||||
return <ExternalIframePage src={src} title="网络xwt资源管理" />;
|
||||
};
|
||||
|
||||
export default NetworkActorResourceManagement;
|
||||
@@ -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 FanganTyPage: React.FC = () => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === "dark";
|
||||
const [iframeUrl, setIframeUrl] = useState("");
|
||||
useEffect(() => {
|
||||
initIframeUrl();
|
||||
}, []);
|
||||
const initIframeUrl = () => {
|
||||
const url = `${TYURL}/fromB/toPage?defaultPage=iframeFaty`;
|
||||
console.log({ iframeUrl: url });
|
||||
setIframeUrl(url);
|
||||
};
|
||||
return (
|
||||
<div className="iframe-container">
|
||||
<iframe src={iframeUrl} className="full-size-iframe"></iframe>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FanganTyPage;
|
||||
@@ -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<TaskPgFromProps> = ({gopath}) => {
|
||||
const [pgUrl, setPgUrl] = useState(pgLinkUrl);
|
||||
useEffect(() => {
|
||||
if(userInfo) {
|
||||
const userData = JSON.parse(userInfo);
|
||||
setPgUrl(`${pgUrl}?access_token=${userData.accessToken}&goPath=${gopath}`);
|
||||
}
|
||||
}, [gopath]);
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<iframe className="h-[calc(100vh-92px)]" src={pgUrl}></iframe>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export default TaskPgFrom
|
||||
|
||||
const TaskPgFrom: React.FC<TaskPgFromProps> = ({ gopath }) => {
|
||||
// 每次从 base 重建(修复原先用状态做模板导致查询串累加的 bug);key={src} 强制重载。
|
||||
const src = useMemo(() => getPgIframeUrl(gopath), [gopath]);
|
||||
return <ExternalIframePage src={src} title="PG中任务" />;
|
||||
};
|
||||
|
||||
export default TaskPgFrom;
|
||||
@@ -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<TaskTyFromProps> = ({ defaultPage, title }) => {
|
||||
const src = useMemo(() => getTyIframeUrl(defaultPage), [defaultPage]);
|
||||
return <ExternalIframePage src={src} title={title} />;
|
||||
};
|
||||
|
||||
export default TaskTyFrom;
|
||||
@@ -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<TaskPgFromProps> = ({gopath}) => {
|
||||
const [xkUrl, setXkUrl] = useState(xkLinkUrl);
|
||||
useEffect(() => {
|
||||
if(userInfo) {
|
||||
const userData: any = JSON.parse(userInfo);
|
||||
setXkUrl(`${xkLinkUrl}${gopath}?accessToken=${userData.accessToken}`);
|
||||
}
|
||||
}, [gopath]);
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<iframe className="h-[calc(100vh-92px)]" src={xkUrl}></iframe>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export default TaskXkFrom
|
||||
|
||||
const TaskXkFrom: React.FC<TaskXkFromProps> = ({ gopath }) => {
|
||||
// gopath 变化(菜单切换)时从 base 重建地址;ExternalIframePage 的 key={src} 会强制重载。
|
||||
const src = useMemo(() => getXkIframeUrl(gopath), [gopath]);
|
||||
return <ExternalIframePage src={src} title="XK中任务" />;
|
||||
};
|
||||
|
||||
export default TaskXkFrom;
|
||||
@@ -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 (
|
||||
<div className="iframe-container">
|
||||
<iframe src={iframeUrl} className="full-size-iframe"></iframe>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default XXTyPage;
|
||||
@@ -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 (
|
||||
<div className="iframe-container">
|
||||
<iframe src={iframeUrl} className="full-size-iframe"></iframe>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default XingdongTyPage;
|
||||
@@ -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<string, unknown> {
|
||||
try {
|
||||
const raw = localStorage.getItem("userInfo");
|
||||
if (!raw) return {};
|
||||
const parsed = JSON.parse(raw);
|
||||
return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析单个路由参数在运行时的取值。 */
|
||||
export function resolveRouteParamValue(
|
||||
param: LightAppRouteParam,
|
||||
userInfo: Record<string, unknown> = 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<LightApp, "url" | "routeParams">,
|
||||
userInfo: Record<string, unknown> = 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("&")}`;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
@@ -10,20 +10,6 @@ gateway:
|
||||
port: 8001
|
||||
|
||||
models:
|
||||
<<<<<<< Updated upstream
|
||||
# - name: deepseek-chat
|
||||
# display_name: DeepSeek Chat
|
||||
# use: langchain_openai:ChatOpenAI
|
||||
# model: deepseek-chat
|
||||
# api_key: sk-3fee562f834a4c08bb0d471ef44cb445
|
||||
# base_url: https://api.deepseek.com
|
||||
# request_timeout: 600.0
|
||||
# max_retries: 2
|
||||
# max_tokens: 4096
|
||||
# temperature: 1.0
|
||||
# supports_vision: false
|
||||
# supports_thinking: true
|
||||
=======
|
||||
- name: deepseek-chat
|
||||
display_name: DeepSeek Chat
|
||||
use: langchain_openai:ChatOpenAI
|
||||
@@ -36,7 +22,6 @@ models:
|
||||
temperature: 1.0
|
||||
supports_vision: false
|
||||
supports_thinking: true
|
||||
>>>>>>> Stashed changes
|
||||
|
||||
# - name: zai-org/GLM-5-FP8
|
||||
# display_name: zai-org/GLM-5-FP8
|
||||
|
||||
+23
@@ -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)
|
||||
+46
@@ -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
|
||||
+97
@@ -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
|
||||
+42
@@ -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))
|
||||
+135
@@ -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
|
||||
+2
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user