diff --git a/frontend-web/.env.development b/frontend-web/.env.development index b78f859a..5077f8d4 100644 --- a/frontend-web/.env.development +++ b/frontend-web/.env.development @@ -17,6 +17,9 @@ VITE_MOCK_COP_TASK_DETAIL=true VITE_BACKEND_BASE_URL=http://127.0.0.1:8001 VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api +# 开发环境普通登录后进入 Vite 内隔离的官方风格页面;/page/* 仍访问当前定制版。 +VITE_USE_OFFICIAL_DEERFLOW_ENTRY=true + # 轻应用「新建笔记」系列接口 VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000 # CMZS后端接口 diff --git a/frontend-web/.env.example b/frontend-web/.env.example index 415c1a07..554254f4 100644 --- a/frontend-web/.env.example +++ b/frontend-web/.env.example @@ -60,3 +60,9 @@ # 不填或留空时按类型 A,继续使用原来的简介菜单数据。 # VITE_COMPACT_MENU_TYPE=A # VITE_GITHUB_OAUTH_TOKEN= + +# --- 官方 DeerFlow 风格页面入口(仅 Vite,不引入 Next.js)--- +# true 时,普通登录后进入同一 Vite 应用中隔离的 /official/*。 +# 已指定 redirect 或 taskId/goPath 的登录仍保持原来的优先级。 +# 仅 .env.development 默认 true;其余环境保持 false。 +# VITE_USE_OFFICIAL_DEERFLOW_ENTRY=false diff --git a/frontend-web/.env.network b/frontend-web/.env.network index 9f770d29..a7da4f8a 100644 --- a/frontend-web/.env.network +++ b/frontend-web/.env.network @@ -17,6 +17,9 @@ VITE_MOCK_COP_TASK_DETAIL=false VITE_BACKEND_BASE_URL=http://10.19.200.5:2026 VITE_LANGGRAPH_BASE_URL=http://10.19.200.5:2026/api +# 内网环境保持当前定制版默认入口。 +VITE_USE_OFFICIAL_DEERFLOW_ENTRY=false + # 轻应用「新建笔记」系列接口 VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000 # CMZS后端接口 diff --git a/frontend-web/.env.production b/frontend-web/.env.production index a096e3b5..43085943 100644 --- a/frontend-web/.env.production +++ b/frontend-web/.env.production @@ -14,6 +14,9 @@ VITE_MOCK_SQ_REPORT=false VITE_BACKEND_BASE_URL=https://ch1.b.uat.4.cn/deerflow VITE_LANGGRAPH_BASE_URL=https://ch1.b.uat.4.cn/deerflow/api +# 线上保持当前定制版默认入口。 +VITE_USE_OFFICIAL_DEERFLOW_ENTRY=false + # 轻应用「新建笔记」系列接口 VITE_NOTE_API_BASE_URL=https://your-intranet-host/note-api # CMZS后端接口 diff --git a/frontend-web/.env.sit b/frontend-web/.env.sit index 31dda6ed..67d0332b 100644 --- a/frontend-web/.env.sit +++ b/frontend-web/.env.sit @@ -17,6 +17,9 @@ VITE_MOCK_COP_TASK_DETAIL=false VITE_BACKEND_BASE_URL=https://ch1.b.sit.4.cn/deerflow VITE_LANGGRAPH_BASE_URL=https://ch1.b.sit.4.cn/deerflow/api +# SIT 环境保持当前定制版默认入口。 +VITE_USE_OFFICIAL_DEERFLOW_ENTRY=false + # 轻应用「新建笔记」系列接口 VITE_NOTE_API_BASE_URL=https://your-intranet-host/note-api # CMZS后端接口 diff --git a/frontend-web/src/App.tsx b/frontend-web/src/App.tsx index 7cdb7fa7..679263a3 100644 --- a/frontend-web/src/App.tsx +++ b/frontend-web/src/App.tsx @@ -45,6 +45,7 @@ import { } from "@/position-roundtable/demo/position-roundtable-demo-config"; import { ChatRuntime } from "./pages/WorkspaceRoutes"; import { NotFoundPage } from "./pages/NotFoundPage"; +import OfficialDeerFlowEntry from "./pages/OfficialDeerFlowEntry"; const PUBLIC_EMBED_PREFIXES = [ "/share/", @@ -203,6 +204,7 @@ export function App() { path="/roundtable-dashboard" element={} /> + } /> } /> diff --git a/frontend-web/src/core/official-deerflow/routes.ts b/frontend-web/src/core/official-deerflow/routes.ts new file mode 100644 index 00000000..f684f189 --- /dev/null +++ b/frontend-web/src/core/official-deerflow/routes.ts @@ -0,0 +1,13 @@ +/** + * Hash-router entry used by the customized Vite application for the isolated + * official-style UI. Keeping this prefix distinct from `/page/*` prevents its + * routes and mock state from altering the customized application. + */ +export const OFFICIAL_DEERFLOW_ROUTE_PREFIX = "/official"; + +/** The official-style workspace home shown after a normal login. */ +export const OFFICIAL_DEERFLOW_HOME_ROUTE = + `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/chats/new`; + +/** Existing customized application's normal post-login destination. */ +export const CUSTOMIZED_DEERFLOW_HOME_ROUTE = "/page/workspace/chats/new"; diff --git a/frontend-web/src/env.ts b/frontend-web/src/env.ts index 166e0cec..1458a05a 100644 --- a/frontend-web/src/env.ts +++ b/frontend-web/src/env.ts @@ -41,6 +41,13 @@ export const env = { * flag so operations can switch the shell without rebuilding the frontend. */ FORCE_COLLAPSED_SIDEBAR: readRuntimeFlag("VITE_FORCE_COLLAPSED_SIDEBAR"), + /** + * Development-only default entry switch for the isolated Vite-native + * official-style workspace. It never starts or embeds a Next.js app. + */ + USE_OFFICIAL_DEERFLOW_ENTRY: readRuntimeFlag( + "VITE_USE_OFFICIAL_DEERFLOW_ENTRY", + ), /** * Compact-menu variant for this website. Pair with the admin 简介模式 * type (A / B) so two deployments can show different left-nav items. diff --git a/frontend-web/src/pages/LoginPage.tsx b/frontend-web/src/pages/LoginPage.tsx index c1882731..02a25fcf 100644 --- a/frontend-web/src/pages/LoginPage.tsx +++ b/frontend-web/src/pages/LoginPage.tsx @@ -37,7 +37,6 @@ import { isWujieEnv, useWujieProps } from '@/core/embed/wujie'; import { isEmbeddedHost } from '@/core/embed/frame-context'; import { readEmbedParam } from '@/core/embed/embed-session'; import { - GENERAL_QA_ROUTE, loginByYUserId, persistAccessToken, persistToolsToken, @@ -61,6 +60,11 @@ import { persistYLogToken } from '@/core/log/y-log-token'; import { persistLoginCredentials } from '@/core/auth/login-credentials'; import { OptType, writeYLog } from '@/core/log/y-log'; import { authStorageGet } from '@/core/auth/scoped-storage'; +import { + CUSTOMIZED_DEERFLOW_HOME_ROUTE, + OFFICIAL_DEERFLOW_HOME_ROUTE, +} from '@/core/official-deerflow/routes'; +import { env } from '@/env'; /** 令牌脱敏:日志里只露头尾,避免完整令牌泄漏。 */ function maskLoginToken(value: string): string { @@ -103,7 +107,11 @@ function summarizeStoredUserInfo(): Record | null { const DEFAULT_USERNAME = 'guest'; const IFRAME_CHAT_MODE = 'iframe'; -const DEFAULT_REDIRECT_PATH = '/page/workspace/chats/new'; +// Explicit redirects and task deep links retain their existing higher +// priority. This only picks the destination for a standard completed login. +const DEFAULT_REDIRECT_PATH = env.USE_OFFICIAL_DEERFLOW_ENTRY + ? OFFICIAL_DEERFLOW_HOME_ROUTE + : CUSTOMIZED_DEERFLOW_HOME_ROUTE; const FULL_CHAT_MODE = 'full'; type LoginStatus = 'loading' | 'success' | 'error'; @@ -191,7 +199,7 @@ export default function LoginPage() { // External iframe callers frequently also provide yUserId for login. // That identity parameter must not steal a deliberate iframe entry and // send it to the generic full-chat home before the route split above. - if (yUserId) return GENERAL_QA_ROUTE; + if (yUserId) return DEFAULT_REDIRECT_PATH; return resolvePostLoginRedirectPath( searchParams.toString(), DEFAULT_REDIRECT_PATH, diff --git a/frontend-web/src/pages/OfficialDeerFlowEntry.tsx b/frontend-web/src/pages/OfficialDeerFlowEntry.tsx new file mode 100644 index 00000000..fa1df75d --- /dev/null +++ b/frontend-web/src/pages/OfficialDeerFlowEntry.tsx @@ -0,0 +1,776 @@ +import type { Message } from "@langchain/langgraph-sdk"; +import { + Bot, + CalendarClock, + ChevronDown, + CircleHelp, + Clock3, + LoaderCircle, + MessageSquareText, + PanelLeftClose, + Pause, + Play, + PlusSquare, + Search, + SendHorizontal, + Settings2, + Sparkles, + UserRound, + X, +} from "lucide-react"; +import { type FormEvent, useCallback, useMemo, useState } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; + +import { useAgent, useAgents } from "@/core/agents"; +import { getAccountDisplayName } from "@/core/auth"; +import { + type ScheduledTask, + useScheduledTaskActions, + 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 { 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 "./official-deerflow-entry.css"; + +type OfficialPage = "chat" | "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/agents")) return "agents"; + if (path.startsWith("/workspace/scheduled-tasks")) return "scheduled"; + if (path.startsWith("/workspace/settings")) return "settings"; + return "chat"; +} + +function parseChatRoute(pathname: string) { + const chatMatch = pathname.match(/\/workspace\/chats\/([^/]+)/); + if (chatMatch) return { threadId: decodeURIComponent(chatMatch[1]!), agentId: null }; + + const agentMatch = pathname.match( + /\/workspace\/agents\/([^/]+)\/chats\/([^/]+)/, + ); + if (agentMatch) { + return { + agentId: decodeURIComponent(agentMatch[1]!), + threadId: decodeURIComponent(agentMatch[2]!), + }; + } + return { threadId: "new", agentId: null }; +} + +function parseScheduledTaskId(pathname: string) { + const match = pathname.match(/\/workspace\/scheduled-tasks\/([^/]+)/); + return match ? decodeURIComponent(match[1]!) : null; +} + +function readableError(error: unknown, fallback: string) { + return error instanceof Error && error.message ? error.message : fallback; +} + +function formatTimestamp(value: string | null | undefined) { + if (!value) return ""; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ""; + const elapsed = Date.now() - date.getTime(); + if (elapsed < 60_000) return "刚刚"; + if (elapsed < 3_600_000) return `${Math.max(1, Math.floor(elapsed / 60_000))} 分钟前`; + if (elapsed < 86_400_000) return `${Math.floor(elapsed / 3_600_000)} 小时前`; + return new Intl.DateTimeFormat("zh-CN", { month: "numeric", day: "numeric" }).format(date); +} + +function statusForTask(task: ScheduledTask) { + const status = task.last_status; + if (status === "running" || status === "pending") { + return { className: status, label: "执行中" }; + } + if (!task.enabled) return { className: "paused", label: "已暂停" }; + if (status === "failed") return { className: "failed", label: "执行失败" }; + if (status === "succeeded") return { className: "succeeded", label: "已完成" }; + return { className: "ready", label: "已启用" }; +} + +function runStatusText(status: string) { + if (status === "running" || status === "pending") return "执行中"; + if (status === "succeeded") return "已完成"; + if (status === "failed") return "执行失败"; + return status || "等待执行"; +} + +function OfficialNavItem({ + active, + icon: Icon, + label, + onClick, +}: { + active: boolean; + icon: typeof MessageSquareText; + label: string; + onClick: () => void; +}) { + return ( + + ); +} + +function ThreadMessage({ message }: { message: Message }) { + const text = textOfMessage(message)?.trim(); + if (!text) return null; + const isUser = message.type === "human"; + return ( +
+ {!isUser && ( +
+
+ )} +

{text}

+
+ ); +} + +function ChatWorkspace({ + agentId, + routeThreadId, +}: { + agentId: string | null; + routeThreadId: string; +}) { + const navigate = useNavigate(); + const [prompt, setPrompt] = useState(""); + const [sendError, setSendError] = useState(null); + const isNewThread = routeThreadId === "new"; + // 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, + context: { + ...localSettings.context, + ...(agentId ? { agent_id: agentId } : {}), + }, + onStart: (createdThreadId) => { + const nextPath = agentId + ? `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/agents/${encodeURIComponent(agentId)}/chats/${encodeURIComponent(createdThreadId)}` + : `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/chats/${encodeURIComponent(createdThreadId)}`; + navigate(nextPath, { replace: true }); + }, + }); + + const messages = useMemo( + () => + (thread.messages ?? []).filter( + (message): message is Message => + message.type === "human" || message.type === "ai", + ), + [thread.messages], + ); + const title = isNewThread + ? agent?.name ?? "新对话" + : thread.values?.title ?? agent?.name ?? "对话"; + const streamError = thread.error + ? readableError(thread.error, "对话加载失败,请稍后重试。") + : null; + + const handleSubmit = useCallback( + (event: FormEvent) => { + event.preventDefault(); + const text = prompt.trim(); + if (!text || thread.isLoading || isUploading) return; + setPrompt(""); + setSendError(null); + void sendMessage(activeThreadId, { text, files: [] }).catch((error) => { + setSendError(readableError(error, "消息发送失败,请稍后重试。")); + }); + }, + [activeThreadId, isUploading, prompt, sendMessage, thread.isLoading], + ); + + return ( +
+
+
+ {title} +
+
+ + +
+
+ +
+ {messages.length ? ( +
+ {messages.map((message, index) => ( + + ))} + {thread.isLoading && ( +
+
+ )} +
+ ) : ( +
+
+
+

{agent?.name ? `与 ${agent.name} 开始对话` : "今天想一起完成什么?"}

+

直接连接当前 DeerFlow 后端;消息与历史记录会同步保存。

+ {!agentId && ( +
+ {[ + ["做一份市场调研", "比较行业趋势并形成要点"], + ["规划项目", "把目标拆解成清晰步骤"], + ["写一篇初稿", "从想法到结构化内容"], + ].map(([suggestion, detail]) => ( + + ))} +
+ )} +
+ )} +
+ + {(streamError || sendError) && ( +

+ {sendError ?? streamError} +

+ )} +
+
+