From a26fd32ebadfa6dca3710c92b46ed5eea1051d76 Mon Sep 17 00:00:00 2001 From: Administrator Date: Wed, 9 Sep 2026 15:40:29 +0800 Subject: [PATCH] feat: add unified strategy project workflow --- .../workspace/agents/agent-chat-sidebar.tsx | 16 +- .../workspace/agents/agent-recent-chats.tsx | 25 +- .../workspace/general-qa-sidebar.tsx | 16 +- .../workspace/model-name-select.tsx | 4 +- .../workspace/my-project-thread-indicator.tsx | 51 + .../components/workspace/recent-chat-list.tsx | 30 +- .../workspace/workspace-nav-chat-list.tsx | 11 +- .../src/core/page-layout/sidebar-menu.ts | 10 +- .../src/core/threads/project-metadata.test.ts | 46 + .../src/core/threads/project-metadata.ts | 94 ++ frontend-web/src/pages/ChatPage.tsx | 42 +- .../src/pages/OfficialDeerFlowEntry.tsx | 126 +- frontend-web/src/pages/StrategyRoutes.tsx | 23 +- .../src/pages/official-deerflow-entry.css | 2 + .../strategy-selection/AgentConfigDialog.tsx | 154 ++- .../StrategyProjectsPage.tsx | 1058 +++++++++++++++++ .../StrategySelectionPage.tsx | 824 +++++++++++-- .../src/strategy-selection/projects.ts | 336 ++++++ offline-backend-20260512/backend/CLAUDE.md | 11 + offline-backend-20260512/backend/README.md | 9 +- .../backend/app/gateway/routers/agents.py | 120 +- .../app/gateway/routers/parallel_agents.py | 39 +- .../backend/extensions_config.json | 2 +- .../llm_error_handling_middleware.py | 14 +- .../harness/deerflow/models/patched_openai.py | 97 +- .../test_llm_error_handling_middleware.py | 100 +- .../tests/test_roundtable_model_fallback.py | 180 +++ .../tests/test_strategy_selection_seed.py | 133 +++ 28 files changed, 3382 insertions(+), 191 deletions(-) create mode 100644 frontend-web/src/components/workspace/my-project-thread-indicator.tsx create mode 100644 frontend-web/src/core/threads/project-metadata.test.ts create mode 100644 frontend-web/src/core/threads/project-metadata.ts create mode 100644 frontend-web/src/strategy-selection/StrategyProjectsPage.tsx create mode 100644 frontend-web/src/strategy-selection/projects.ts diff --git a/frontend-web/src/components/workspace/agents/agent-chat-sidebar.tsx b/frontend-web/src/components/workspace/agents/agent-chat-sidebar.tsx index 45f39694..cf44531f 100644 --- a/frontend-web/src/components/workspace/agents/agent-chat-sidebar.tsx +++ b/frontend-web/src/components/workspace/agents/agent-chat-sidebar.tsx @@ -51,6 +51,10 @@ import { SelectValue, } from "@/components/ui/select"; import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs"; +import { + MyProjectThreadBadge, + MyProjectThreadIcon, +} from "@/components/workspace/my-project-thread-indicator"; import { useTheme } from "@/contexts/ThemeContext"; import { useAgentsPaginated } from "@/core/agents"; import { getAPIClient } from "@/core/api"; @@ -69,6 +73,7 @@ import { useToggleThreadPin, } from "@/core/threads/hooks"; import type { AgentThread, AgentThreadState } from "@/core/threads/types"; +import { myProjectConversationTypeOfThread } from "@/core/threads/project-metadata"; import { bootstrapResumePath, isBootstrapThread, @@ -479,6 +484,7 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) { ) : ( filteredThreads.map((thread) => { const isBoot = isBootstrapThread(thread); + const projectType = myProjectConversationTypeOfThread(thread); const href = isBoot ? bootstrapResumePath(thread.thread_id) : pathOf(thread); const isActive = href === pathname; const pinned = isThreadPinned(thread); @@ -505,6 +511,8 @@ function RecentChatsPane({ activeAgentId }: { activeAgentId?: string }) { )} aria-label="智能体创建会话" /> + ) : projectType ? ( + ) : ( )} - + {desensitize(titleOfThread(thread, t.pages.newChat))} + {projectType && ( + + )} {isActive && ( )} diff --git a/frontend-web/src/components/workspace/agents/agent-recent-chats.tsx b/frontend-web/src/components/workspace/agents/agent-recent-chats.tsx index 3bdf1204..3207c8a3 100644 --- a/frontend-web/src/components/workspace/agents/agent-recent-chats.tsx +++ b/frontend-web/src/components/workspace/agents/agent-recent-chats.tsx @@ -4,7 +4,12 @@ import { MessageSquareIcon } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; +import { + MyProjectThreadBadge, + MyProjectThreadIcon, +} from "@/components/workspace/my-project-thread-indicator"; import { useThreadsPage } from "@/core/threads/hooks"; +import { myProjectConversationTypeOfThread } from "@/core/threads/project-metadata"; import { pathOfThread, titleOfThread, isHiddenFromRecentChats, isBootstrapThread } from "@/core/threads/utils"; import { desensitize } from "@/lib/desensitize"; import { cn } from "@/lib/utils"; @@ -45,6 +50,7 @@ export function AgentRecentChats({ agentId }: { agentId: string }) { {threads.map((thread) => { const href = pathOfThread(thread); const isActive = href === pathname; + const projectType = myProjectConversationTypeOfThread(thread); return ( - - + {projectType ? ( + + ) : ( + + )} + {desensitize(titleOfThread(thread, "新对话"))} + {projectType && } ); })} diff --git a/frontend-web/src/components/workspace/general-qa-sidebar.tsx b/frontend-web/src/components/workspace/general-qa-sidebar.tsx index 43e6062d..2ff4ecba 100644 --- a/frontend-web/src/components/workspace/general-qa-sidebar.tsx +++ b/frontend-web/src/components/workspace/general-qa-sidebar.tsx @@ -60,6 +60,10 @@ import { } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs"; +import { + MyProjectThreadBadge, + MyProjectThreadIcon, +} from "@/components/workspace/my-project-thread-indicator"; import { useAgentsPaginated, type Agent } from "@/core/agents"; import { getAPIClient } from "@/core/api"; import { useI18n } from "@/core/i18n/hooks"; @@ -75,6 +79,7 @@ import { useToggleThreadPin, } from "@/core/threads/hooks"; import type { AgentThread, AgentThreadState } from "@/core/threads/types"; +import { myProjectConversationTypeOfThread } from "@/core/threads/project-metadata"; import { isBootstrapThread, isHiddenFromRecentChats, @@ -483,6 +488,7 @@ export function GeneralQaSidebar() { ); const isActive = href === pathname; const isBoot = isBootstrapThread(thread); + const projectType = myProjectConversationTypeOfThread(thread); const pinned = isThreadPinned(thread); const favorited = isThreadFavorite(thread); return ( @@ -507,6 +513,8 @@ export function GeneralQaSidebar() { )} aria-label="智能体创建会话" /> + ) : projectType ? ( + ) : ( )} - + {desensitize(titleOfThread(thread, t.pages.newChat))} + {projectType && ( + + )} {isActive && ( )} diff --git a/frontend-web/src/components/workspace/model-name-select.tsx b/frontend-web/src/components/workspace/model-name-select.tsx index 327aaa63..7ee1afa4 100644 --- a/frontend-web/src/components/workspace/model-name-select.tsx +++ b/frontend-web/src/components/workspace/model-name-select.tsx @@ -25,6 +25,7 @@ export function ModelNameSelect({ className, triggerClassName, defaultLabel = "默认模型", + title = "选择用于本次操作的大模型(默认使用系统配置的模型)", }: { value: string | null; onChange: (modelName: string | null) => void; @@ -32,6 +33,7 @@ export function ModelNameSelect({ className?: string; triggerClassName?: string; defaultLabel?: string; + title?: string; }) { const { models } = useModels(); return ( @@ -42,7 +44,7 @@ export function ModelNameSelect({ > diff --git a/frontend-web/src/components/workspace/my-project-thread-indicator.tsx b/frontend-web/src/components/workspace/my-project-thread-indicator.tsx new file mode 100644 index 00000000..56af656a --- /dev/null +++ b/frontend-web/src/components/workspace/my-project-thread-indicator.tsx @@ -0,0 +1,51 @@ +import { FolderKanban } from "lucide-react"; + +import { + MY_PROJECT_CONVERSATION_LABELS, + type MyProjectConversationType, +} from "@/core/threads/project-metadata"; +import { cn } from "@/lib/utils"; + +const BADGE_STYLES: Record = { + qa: "border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-700/70 dark:bg-sky-950/55 dark:text-sky-300", + strategy: + "border-violet-200 bg-violet-50 text-violet-700 dark:border-violet-700/70 dark:bg-violet-950/55 dark:text-violet-300", + solution: + "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-700/70 dark:bg-emerald-950/55 dark:text-emerald-300", +}; + +export function MyProjectThreadIcon({ + type, + className, +}: { + type: MyProjectConversationType; + className?: string; +}) { + return ( + + ); +} + +export function MyProjectThreadBadge({ + type, + className, +}: { + type: MyProjectConversationType; + className?: string; +}) { + return ( + + {MY_PROJECT_CONVERSATION_LABELS[type]} + + ); +} diff --git a/frontend-web/src/components/workspace/recent-chat-list.tsx b/frontend-web/src/components/workspace/recent-chat-list.tsx index f0bbb213..73ffd7cc 100644 --- a/frontend-web/src/components/workspace/recent-chat-list.tsx +++ b/frontend-web/src/components/workspace/recent-chat-list.tsx @@ -49,7 +49,11 @@ import { SidebarMenuButton, SidebarMenuItem, } from "@/components/ui/sidebar"; -import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs"; +import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs"; +import { + MyProjectThreadBadge, + MyProjectThreadIcon, +} from "@/components/workspace/my-project-thread-indicator"; import { getAPIClient } from "@/core/api"; import { useI18n } from "@/core/i18n/hooks"; import { @@ -63,7 +67,8 @@ import { useToggleThreadFavorite, useToggleThreadPin, } from "@/core/threads/hooks"; -import type { AgentThread, AgentThreadState } from "@/core/threads/types"; +import type { AgentThread, AgentThreadState } from "@/core/threads/types"; +import { myProjectConversationTypeOfThread } from "@/core/threads/project-metadata"; import { isBootstrapThread, isHiddenFromRecentChats, @@ -245,7 +250,8 @@ export function RecentChatList({ {shownThreads.map((thread) => { const href = listPathOfThread(thread); const isActive = href === pathname; - const isBoot = isBootstrapThread(thread); + const isBoot = isBootstrapThread(thread); + const projectType = myProjectConversationTypeOfThread(thread); const pinned = isThreadPinned(thread); const favorited = isThreadFavorite(thread); return ( @@ -279,8 +285,10 @@ export function RecentChatList({ } aria-label="智能体创建会话" /> - ) : ( - compact && ( + ) : projectType ? ( + + ) : ( + compact && ( )} - - {desensitize(titleOfThread(thread, t.pages.newChat))} - + + {desensitize(titleOfThread(thread, t.pages.newChat))} + + {projectType && ( + + )} {env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && ( diff --git a/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx b/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx index bcd222ed..27d7fbd1 100644 --- a/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx +++ b/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx @@ -14,7 +14,7 @@ import { SidebarMenuItem, } from "@/components/ui/sidebar"; import { useI18n } from "@/core/i18n/hooks"; -import { BookOpen, BotIcon, BrainIcon, FileSearch, FileText, Library, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react"; +import { BookOpen, BotIcon, BrainIcon, FileSearch, FileText, FolderKanban, Library, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Telescope, Users } from "lucide-react"; import { KnowledgeRagToggle } from "./knowledge-rag-toggle"; import { SidebarCollapseButton } from "./workspace-header"; @@ -65,6 +65,15 @@ export const WORKSPACE_COMPACT_NAV_ITEMS: WorkspaceCompactNavItem[] = [ path: "/page/workspace/chats", match: (pathname) => pathname === "/page/workspace/chats", }, + { + id: "strategy-projects", + label: "我的项目", + icon: FolderKanban, + path: "/page/strategy/qa/projects", + match: (pathname) => + pathname.startsWith("/page/strategy/qa/projects") || + pathname.startsWith("/page/strategy/qa/strategy-selection"), + }, { id: "workspace-library", label: "资料库", diff --git a/frontend-web/src/core/page-layout/sidebar-menu.ts b/frontend-web/src/core/page-layout/sidebar-menu.ts index 3fdab91f..1a2e8eed 100644 --- a/frontend-web/src/core/page-layout/sidebar-menu.ts +++ b/frontend-web/src/core/page-layout/sidebar-menu.ts @@ -21,6 +21,7 @@ import { FileBox, FileSearch, FileText, + FolderKanban, Gauge, Grid3X3, HelpCircle, @@ -69,10 +70,11 @@ export const sidebarMenuItems: MenuItem[] = [ path: "/page/strategy/qa/multi-agent-roundtable", }, { - id: "strategy-selection", - label: "策略优选", - icon: Sparkles, - path: "/page/strategy/qa/strategy-selection", + id: "strategy-projects", + label: "我的项目", + icon: FolderKanban, + path: "/page/strategy/qa/projects", + aliasPaths: ["/page/strategy/qa/strategy-selection"], }, // { // id: "position-roundtable", diff --git a/frontend-web/src/core/threads/project-metadata.test.ts b/frontend-web/src/core/threads/project-metadata.test.ts new file mode 100644 index 00000000..74be85e7 --- /dev/null +++ b/frontend-web/src/core/threads/project-metadata.test.ts @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + myProjectConversationTypeFromSearchParams, + myProjectConversationTypeOfThread, + myProjectThreadMetadata, + withMyProjectConversationMarker, +} from "./project-metadata"; + +test("project conversation type is read from persisted metadata", () => { + assert.equal( + myProjectConversationTypeOfThread({ + metadata: myProjectThreadMetadata("strategy"), + }), + "strategy", + ); + assert.equal( + myProjectConversationTypeOfThread({ + metadata: { project_source: "elsewhere", project_type: "qa" }, + }), + null, + ); +}); + +test("metadata wins over the compatibility context fallback", () => { + assert.equal( + myProjectConversationTypeOfThread({ + metadata: myProjectThreadMetadata("solution"), + context: myProjectThreadMetadata("qa"), + }), + "solution", + ); +}); + +test("new-chat route marker round-trips all supported project types", () => { + for (const type of ["qa", "strategy", "solution"] as const) { + const path = withMyProjectConversationMarker( + "/page/strategy/chats/new?keep=1", + type, + ); + const params = new URLSearchParams(path.split("?", 2)[1]); + assert.equal(params.get("keep"), "1"); + assert.equal(myProjectConversationTypeFromSearchParams(params), type); + } +}); diff --git a/frontend-web/src/core/threads/project-metadata.ts b/frontend-web/src/core/threads/project-metadata.ts new file mode 100644 index 00000000..a7bef103 --- /dev/null +++ b/frontend-web/src/core/threads/project-metadata.ts @@ -0,0 +1,94 @@ +/** + * Stable marker for conversations entered from 「我的项目」. + * + * Keep this separate from ``project_id``: that key belongs to DeerFlow's + * folder-style conversation projects, while this marker describes the product + * mode that created (or explicitly continued) a conversation. + */ +export const MY_PROJECT_THREAD_SOURCE = "my-projects" as const; + +export const MY_PROJECT_THREAD_SOURCE_KEY = "project_source" as const; +export const MY_PROJECT_THREAD_TYPE_KEY = "project_type" as const; + +export const MY_PROJECT_QUERY_SOURCE_KEY = "projectSource" as const; +export const MY_PROJECT_QUERY_TYPE_KEY = "projectType" as const; + +export type MyProjectConversationType = "qa" | "strategy" | "solution"; + +export const MY_PROJECT_CONVERSATION_LABELS: Record< + MyProjectConversationType, + string +> = { + qa: "普通问答", + strategy: "策略优选", + solution: "方案生成", +}; + +const MY_PROJECT_CONVERSATION_TYPES = new Set([ + "qa", + "strategy", + "solution", +]); + +function typeFromRecord( + value: Record | null | undefined, +): MyProjectConversationType | null { + if (value?.[MY_PROJECT_THREAD_SOURCE_KEY] !== MY_PROJECT_THREAD_SOURCE) { + return null; + } + const type = value[MY_PROJECT_THREAD_TYPE_KEY]; + return typeof type === "string" && + MY_PROJECT_CONVERSATION_TYPES.has(type as MyProjectConversationType) + ? (type as MyProjectConversationType) + : null; +} + +export function myProjectThreadMetadata( + type: MyProjectConversationType, +): Record { + return { + [MY_PROJECT_THREAD_SOURCE_KEY]: MY_PROJECT_THREAD_SOURCE, + [MY_PROJECT_THREAD_TYPE_KEY]: type, + }; +} + +/** Read persisted metadata first; context is only a compatibility fallback. */ +export function myProjectConversationTypeOfThread(thread: { + metadata?: Record | null; + context?: Record | null; +}): MyProjectConversationType | null { + return typeFromRecord(thread.metadata) ?? typeFromRecord(thread.context); +} + +export function myProjectConversationTypeFromSearchParams(params: { + get(name: string): string | null; +}): MyProjectConversationType | null { + if (params.get(MY_PROJECT_QUERY_SOURCE_KEY) !== MY_PROJECT_THREAD_SOURCE) { + return null; + } + const type = params.get(MY_PROJECT_QUERY_TYPE_KEY); + return typeof type === "string" && + MY_PROJECT_CONVERSATION_TYPES.has(type as MyProjectConversationType) + ? (type as MyProjectConversationType) + : null; +} + +/** Add an explicit, one-shot marker to a new-conversation route. */ +export function withMyProjectConversationMarker( + path: string, + type: MyProjectConversationType, +): string { + const hashIndex = path.indexOf("#"); + const hash = hashIndex >= 0 ? path.slice(hashIndex) : ""; + const pathWithoutHash = hashIndex >= 0 ? path.slice(0, hashIndex) : path; + const queryIndex = pathWithoutHash.indexOf("?"); + const pathname = + queryIndex >= 0 ? pathWithoutHash.slice(0, queryIndex) : pathWithoutHash; + const params = new URLSearchParams( + queryIndex >= 0 ? pathWithoutHash.slice(queryIndex + 1) : "", + ); + params.set(MY_PROJECT_QUERY_SOURCE_KEY, MY_PROJECT_THREAD_SOURCE); + params.set(MY_PROJECT_QUERY_TYPE_KEY, type); + const search = params.toString(); + return `${pathname}${search ? `?${search}` : ""}${hash}`; +} diff --git a/frontend-web/src/pages/ChatPage.tsx b/frontend-web/src/pages/ChatPage.tsx index 046708a2..49e25ab7 100644 --- a/frontend-web/src/pages/ChatPage.tsx +++ b/frontend-web/src/pages/ChatPage.tsx @@ -64,6 +64,10 @@ import { useThreadSettings, } from "@/core/settings"; import { useThreadStream, stopActiveThreadBeforeClarificationReply } from "@/core/threads/hooks"; +import { + myProjectConversationTypeFromSearchParams, + myProjectThreadMetadata, +} from "@/core/threads/project-metadata"; import { useResearchWritingFeed } from "@/core/threads/use-research-writing-feed"; import { clearResearchReportStream } from "@/core/threads/use-research-report-stream"; import { resolveWritingModeReportPath } from "@/core/threads/writing-mode"; @@ -285,16 +289,28 @@ export default function ChatPage() { }, [selectedAgentId, selectorAgents, mineAgents, squareAgents, selectorLoading]); const { showNotification } = useNotification(); + const projectConversationType = + myProjectConversationTypeFromSearchParams(searchParams); + const newThreadMetadata = useMemo | undefined>(() => { + const metadata: Record = projectConversationType + ? myProjectThreadMetadata(projectConversationType) + : {}; + if (knowledgeSelectionReady) { + metadata.llmwiki_knowledge_base_ids = selectedKnowledgeBaseIds; + metadata.assistant_knowledge_base_ids = selectedAssistantKnowledgeBaseIds; + } + return Object.keys(metadata).length > 0 ? metadata : undefined; + }, [ + knowledgeSelectionReady, + projectConversationType, + selectedAssistantKnowledgeBaseIds, + selectedKnowledgeBaseIds, + ]); const [thread, sendMessage, isUploading] = useThreadStream({ threadId: isNewThread ? undefined : threadId, context: streamContext, - threadMetadata: knowledgeSelectionReady - ? { - llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds, - assistant_knowledge_base_ids: selectedAssistantKnowledgeBaseIds, - } - : undefined, + threadMetadata: newThreadMetadata, isMock, onStart: (createdThreadId) => { knowledgeSelectionThread.current = createdThreadId; @@ -405,7 +421,9 @@ export default function ChatPage() { try { let targetThreadId = threadId; if (isNewThread) { - const created = await getAPIClient(isMock).threads.create({}); + const created = await getAPIClient(isMock).threads.create({ + metadata: newThreadMetadata ?? {}, + }); targetThreadId = created.thread_id; setThreadId(targetThreadId); setIsNewThread(false); @@ -465,13 +483,8 @@ export default function ChatPage() { try { const created = await getAPIClient(isMock).threads.create({ metadata: { + ...newThreadMetadata, agent_id: selectedAgentId, - ...(knowledgeSelectionReady - ? { - llmwiki_knowledge_base_ids: selectedKnowledgeBaseIds, - assistant_knowledge_base_ids: selectedAssistantKnowledgeBaseIds, - } - : {}), }, }); sessionStorage.setItem( @@ -490,7 +503,7 @@ export default function ChatPage() { ); saveLastAgentId(selectedAgentId); router.push( - `/page/workspace/agents/${selectedAgentId}/chats/${created.thread_id}`, + `${routeBase}/agents/${encodeURIComponent(selectedAgentId)}/chats/${encodeURIComponent(created.thread_id)}`, ); } catch (err) { agentHandoffPendingRef.current = false; @@ -549,6 +562,7 @@ export default function ChatPage() { selectedAssistantKnowledgeBaseIds, selectedKnowledgeBaseIds, knowledgeSelectionReady, + newThreadMetadata, agentHandoffPending, isMock, router, diff --git a/frontend-web/src/pages/OfficialDeerFlowEntry.tsx b/frontend-web/src/pages/OfficialDeerFlowEntry.tsx index fa1df75d..3945cae9 100644 --- a/frontend-web/src/pages/OfficialDeerFlowEntry.tsx +++ b/frontend-web/src/pages/OfficialDeerFlowEntry.tsx @@ -5,6 +5,7 @@ import { ChevronDown, CircleHelp, Clock3, + FolderKanban, LoaderCircle, MessageSquareText, PanelLeftClose, @@ -20,7 +21,12 @@ import { } from "lucide-react"; import { type FormEvent, useCallback, useMemo, useState } from "react"; import { useLocation, useNavigate } from "react-router-dom"; +import { Toaster } from "sonner"; +import { + MyProjectThreadBadge, + MyProjectThreadIcon, +} from "@/components/workspace/my-project-thread-indicator"; import { useAgent, useAgents } from "@/core/agents"; import { getAccountDisplayName } from "@/core/auth"; import { @@ -29,28 +35,41 @@ import { useScheduledTaskRuns, useScheduledTasks, } from "@/core/scheduled-tasks"; -import { useLocalSettings } from "@/core/settings"; import { useAppearanceSettings, useCitationDisplaySettings, } from "@/core/system-settings/hooks"; import { useThreadStream, useThreads } from "@/core/threads/hooks"; +import { + myProjectConversationTypeOfThread, + myProjectConversationTypeFromSearchParams, + myProjectThreadMetadata, +} from "@/core/threads/project-metadata"; import { textOfMessage, titleOfThread } from "@/core/threads/utils"; import { uuid } from "@/core/utils/uuid"; import { OFFICIAL_DEERFLOW_HOME_ROUTE, OFFICIAL_DEERFLOW_ROUTE_PREFIX, } from "@/core/official-deerflow/routes"; +import StrategyProjectsPage from "@/strategy-selection/StrategyProjectsPage"; +import StrategySelectionPage from "@/strategy-selection/StrategySelectionPage"; import "./official-deerflow-entry.css"; -type OfficialPage = "chat" | "agents" | "agent-chat" | "scheduled" | "settings"; +type OfficialPage = + | "chat" + | "projects" + | "agents" + | "agent-chat" + | "scheduled" + | "settings"; function pageFromPath(pathname: string): OfficialPage { const path = pathname.slice(OFFICIAL_DEERFLOW_ROUTE_PREFIX.length); if (path.startsWith("/workspace/agents/") && path.includes("/chats")) { return "agent-chat"; } + if (path.startsWith("/workspace/projects")) return "projects"; if (path.startsWith("/workspace/agents")) return "agents"; if (path.startsWith("/workspace/scheduled-tasks")) return "scheduled"; if (path.startsWith("/workspace/settings")) return "settings"; @@ -78,6 +97,11 @@ function parseScheduledTaskId(pathname: string) { return match ? decodeURIComponent(match[1]!) : null; } +function parseProjectId(pathname: string) { + const match = pathname.match(/\/workspace\/projects\/([^/]+)/); + return match ? decodeURIComponent(match[1]!) : null; +} + function readableError(error: unknown, fallback: string) { return error instanceof Error && error.message ? error.message : fallback; } @@ -159,20 +183,36 @@ function ChatWorkspace({ routeThreadId: string; }) { const navigate = useNavigate(); + const location = useLocation(); const [prompt, setPrompt] = useState(""); const [sendError, setSendError] = useState(null); const isNewThread = routeThreadId === "new"; + const projectConversationType = isNewThread + ? myProjectConversationTypeFromSearchParams( + new URLSearchParams(location.search), + ) + : null; // A new conversation uses the same client-generated id approach as the // existing ChatPage. The backend owns the persisted id once streaming starts. const [draftThreadId] = useState(() => uuid() as string); const activeThreadId = isNewThread ? draftThreadId : routeThreadId; const { agent } = useAgent(agentId, { enabled: !!agentId }); - const [localSettings] = useLocalSettings(); const [thread, sendMessage, isUploading] = useThreadStream({ threadId: isNewThread ? undefined : routeThreadId, assistantId: agentId ?? undefined, + threadMetadata: projectConversationType + ? myProjectThreadMetadata(projectConversationType) + : undefined, context: { - ...localSettings.context, + // This compact official shell has no model/mode controls of its own. + // Do not inherit a hidden selection saved by the customized workspace + // (for example an obsolete or quota-exhausted model); an omitted model + // lets the backend resolve the current system default or the agent's + // globally configured model. + model_name: undefined, + mode: undefined, + reasoning_effort: undefined, + writing_mode: false, ...(agentId ? { agent_id: agentId } : {}), }, onStart: (createdThreadId) => { @@ -658,6 +698,10 @@ export default function OfficialDeerFlowEntry() { () => parseScheduledTaskId(location.pathname), [location.pathname], ); + const projectId = useMemo( + () => parseProjectId(location.pathname), + [location.pathname], + ); const { data: threads = [], isLoading: threadsLoading, error: threadsError } = useThreads({ limit: 20, @@ -666,10 +710,12 @@ export default function OfficialDeerFlowEntry() { select: ["thread_id", "updated_at", "values", "metadata"], }); const go = (path: string) => navigate(path); + const projectBasePath = `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/projects`; const nav = [ { icon: PlusSquare, label: "新对话", path: OFFICIAL_DEERFLOW_HOME_ROUTE, pages: ["chat"] }, { icon: MessageSquareText, label: "对话", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/chats/new`, pages: ["chat"] }, + { icon: FolderKanban, label: "我的项目", path: projectBasePath, pages: ["projects"] }, { icon: Bot, label: "智能体", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/agents`, pages: ["agents", "agent-chat"] }, { icon: CalendarClock, label: "定时任务", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/scheduled-tasks`, pages: ["scheduled"] }, ] as const; @@ -706,29 +752,43 @@ export default function OfficialDeerFlowEntry() { ) : threadsError ? (

对话列表加载失败

) : threads.length ? ( - threads.map((chat) => ( - - )) + threads.map((chat) => { + const projectType = myProjectConversationTypeOfThread(chat); + return ( + + ); + }) ) : (

暂无历史对话

)} @@ -754,6 +814,17 @@ export default function OfficialDeerFlowEntry() { routeThreadId={chatRoute.threadId} /> )} + {page === "projects" && ( + projectId ? ( + + ) : ( + + ) + )} {page === "agents" && ( @@ -771,6 +842,7 @@ export default function OfficialDeerFlowEntry() { {page === "scheduled" && } {page === "settings" && } + ); } diff --git a/frontend-web/src/pages/StrategyRoutes.tsx b/frontend-web/src/pages/StrategyRoutes.tsx index 3245da14..d32b238c 100644 --- a/frontend-web/src/pages/StrategyRoutes.tsx +++ b/frontend-web/src/pages/StrategyRoutes.tsx @@ -1,4 +1,4 @@ -import { Navigate, Route, Routes } from "react-router-dom"; +import { Navigate, Route, Routes, useParams } from "react-router-dom"; import { Toaster } from "sonner"; import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; @@ -78,6 +78,7 @@ import UsersPage from "./UsersPage"; import LlmMetricsPage from "./LlmMetricsPage"; import AdminScheduledTasksPage from "./AdminScheduledTasksPage"; import AdminUserActivityPage from "./AdminUserActivityPage"; +import StrategyProjectsPage from "@/strategy-selection/StrategyProjectsPage"; import StrategySelectionPage from "@/strategy-selection/StrategySelectionPage"; import { ChatRuntime } from "./WorkspaceRoutes"; @@ -99,6 +100,11 @@ function StrategyChatThreadLayout() { ); } +function StrategyProjectWorkbenchRoute() { + const { project_id: projectId = "" } = useParams<{ project_id: string }>(); + return ; +} + export default function StrategyRoutes() { const storedAuth = getStoredAuth(); const canManageBusinessMappings = !storedAuth || storedAuth.system_role === "admin"; @@ -111,7 +117,20 @@ export default function StrategyRoutes() { } /> } /> - } /> + + } + /> + } + /> + } + /> p { color: #90908e; font-size: 12px; margin: 0 8px 9px; } .official-recent-chat { display: grid; font-size: 13px; gap: 8px; grid-template-columns: 15px minmax(0, 1fr) auto; padding: 8px; } .official-recent-chat span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.official-recent-chat-copy { align-items: center; display: flex; gap: 6px; min-width: 0; } +.official-recent-chat-title { flex: 1; min-width: 0; } .official-recent-chat small { color: #acaca9; font-size: 11px; } .official-recent-chat.is-active { background: #efefed; color: #252525; } .official-sidebar-loading, diff --git a/frontend-web/src/strategy-selection/AgentConfigDialog.tsx b/frontend-web/src/strategy-selection/AgentConfigDialog.tsx index 5a6492ea..57e6f204 100644 --- a/frontend-web/src/strategy-selection/AgentConfigDialog.tsx +++ b/frontend-web/src/strategy-selection/AgentConfigDialog.tsx @@ -2,6 +2,9 @@ import { useEffect, useMemo, useState } from "react"; import { Bot, Check, LoaderCircle, Save, Sparkles } from "lucide-react"; import { toast } from "sonner"; +import { SkillTransferSelector } from "@/components/workspace/agents/skill-transfer-selector"; +import { ModelNameSelect } from "@/components/workspace/model-name-select"; +import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -15,6 +18,7 @@ import { import { Textarea } from "@/components/ui/textarea"; import { getAgent, updateAgent } from "@/core/agents/api"; import type { Agent } from "@/core/agents/types"; +import { useAllSkillsAdmin } from "@/core/skills/hooks"; import { cn } from "@/lib/utils"; import { STRATEGY_AGENT_ROLES } from "./constants"; @@ -24,15 +28,33 @@ interface AgentConfigDialogProps { onOpenChange: (open: boolean) => void; } -function parseSkills(value: string): string[] { - return [...new Set(value.split(/[,,\n]/).map((item) => item.trim()).filter(Boolean))]; +interface AgentConfigDraft { + soul: string; + model: string | null; + skills: string[]; +} + +function normalizeSkills(skills: string[]) { + return [...new Set(skills)].sort((left, right) => left.localeCompare(right)); +} + +function sameSkills(left: string[], right: string[]) { + const normalizedLeft = normalizeSkills(left); + const normalizedRight = normalizeSkills(right); + return ( + normalizedLeft.length === normalizedRight.length && + normalizedLeft.every((skill, index) => skill === normalizedRight[index]) + ); } export function AgentConfigDialog({ open, onOpenChange }: AgentConfigDialogProps) { const [selectedId, setSelectedId] = useState(STRATEGY_AGENT_ROLES[0].id); const [agent, setAgent] = useState(null); const [soul, setSoul] = useState(""); - const [skillsText, setSkillsText] = useState(""); + const [model, setModel] = useState(null); + const [selectedSkills, setSelectedSkills] = useState([]); + const [savedDraft, setSavedDraft] = useState(null); + const [detailSkillName, setDetailSkillName] = useState(null); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(""); @@ -41,6 +63,31 @@ export function AgentConfigDialog({ open, onOpenChange }: AgentConfigDialogProps () => STRATEGY_AGENT_ROLES.find((role) => role.id === selectedId)!, [selectedId], ); + const { + skills, + isLoading: isSkillsLoading, + error: skillsError, + } = useAllSkillsAdmin(open); + const selectableSkills = useMemo( + () => + skills.filter((skill) => { + const globallyAvailable = + Boolean(skill.builtin) || Boolean(skill.published); + return ( + skill.enabled && + (skill.state ?? "active") === "active" && + globallyAvailable + ); + }), + [skills], + ); + const dirty = Boolean( + agent && + savedDraft && + (soul !== savedDraft.soul || + model !== savedDraft.model || + !sameSkills(selectedSkills, savedDraft.skills)), + ); useEffect(() => { if (!open || !selectedId) return; @@ -48,12 +95,20 @@ export function AgentConfigDialog({ open, onOpenChange }: AgentConfigDialogProps setLoading(true); setError(""); setAgent(null); + setSavedDraft(null); void getAgent(selectedId) .then((data) => { if (!active) return; setAgent(data); - setSoul(data.soul ?? ""); - setSkillsText((data.skills ?? []).join("\n")); + const nextDraft = { + soul: data.soul ?? "", + model: data.model ?? null, + skills: data.skills ?? [], + }; + setSoul(nextDraft.soul); + setModel(nextDraft.model); + setSelectedSkills(nextDraft.skills); + setSavedDraft(nextDraft); }) .catch((reason: unknown) => { if (!active) return; @@ -77,11 +132,18 @@ export function AgentConfigDialog({ open, onOpenChange }: AgentConfigDialogProps try { const updated = await updateAgent(agent.id, { soul: soul.trim(), - skills: parseSkills(skillsText), + model, + skills: selectedSkills, }); setAgent(updated); setSoul(updated.soul ?? ""); - setSkillsText((updated.skills ?? []).join("\n")); + setModel(updated.model ?? null); + setSelectedSkills(updated.skills ?? []); + setSavedDraft({ + soul: updated.soul ?? "", + model: updated.model ?? null, + skills: updated.skills ?? [], + }); toast.success(`${updated.name} 配置已保存`); } catch (reason) { toast.error(reason instanceof Error ? reason.message : "保存失败"); @@ -90,8 +152,21 @@ export function AgentConfigDialog({ open, onOpenChange }: AgentConfigDialogProps } }; + const confirmDiscardChanges = () => + !dirty || window.confirm("当前智能体配置尚未保存,确定放弃这些修改吗?"); + + const selectRole = (roleId: string) => { + if (roleId === selectedId || saving || !confirmDiscardChanges()) return; + setSelectedId(roleId); + }; + + const handleOpenChange = (nextOpen: boolean) => { + if (!nextOpen && (saving || !confirmDiscardChanges())) return; + onOpenChange(nextOpen); + }; + return ( - + @@ -99,7 +174,7 @@ export function AgentConfigDialog({ open, onOpenChange }: AgentConfigDialogProps 策略智能体配置 - 每个流程角色对应一个默认智能体。管理员可修改 SOUL 和技能;保存后下一次运行即生效。 + 这是全局统一配置,不属于某一个项目。管理员保存后,所有策略项目下一次运行都会使用这里的模型、SOUL 和技能。 @@ -116,7 +191,7 @@ export function AgentConfigDialog({ open, onOpenChange }: AgentConfigDialogProps key={role.id} type="button" disabled={saving} - onClick={() => setSelectedId(role.id)} + onClick={() => selectRole(role.id)} className={cn( "w-full rounded-lg border px-3 py-3 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-60", selected @@ -160,15 +235,22 @@ export function AgentConfigDialog({ open, onOpenChange }: AgentConfigDialogProps ) : agent ? (
-
+
当前名称
{agent.name}
-
+
+ +
-