868 lines
33 KiB
TypeScript
868 lines
33 KiB
TypeScript
import type { Message } from "@langchain/langgraph-sdk";
|
|
import {
|
|
Bot,
|
|
CalendarClock,
|
|
ChevronDown,
|
|
CircleHelp,
|
|
Clock3,
|
|
FolderKanban,
|
|
Globe,
|
|
LoaderCircle,
|
|
MessageSquareText,
|
|
PanelLeftClose,
|
|
Pause,
|
|
Play,
|
|
PlusSquare,
|
|
Search,
|
|
SendHorizontal,
|
|
Settings2,
|
|
Sparkles,
|
|
UserRound,
|
|
X,
|
|
} from "lucide-react";
|
|
import { type FormEvent, useCallback, useMemo, useState } from "react";
|
|
import { Route, Routes, useLocation, useNavigate } from "react-router-dom";
|
|
import AnalysisProjectsPage from "./AnalysisProjectsPage";
|
|
import { SidebarProvider } from "@/components/ui/sidebar";
|
|
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 {
|
|
type ScheduledTask,
|
|
useScheduledTaskActions,
|
|
useScheduledTaskRuns,
|
|
useScheduledTasks,
|
|
} from "@/core/scheduled-tasks";
|
|
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 OfficialWebAppsPage from "@/pages/OfficialWebAppsPage";
|
|
|
|
import "./official-deerflow-entry.css";
|
|
|
|
type OfficialPage =
|
|
| "chat"
|
|
| "projects"
|
|
| "web-apps"
|
|
| "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/web-app")) return "web-apps";
|
|
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 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;
|
|
}
|
|
|
|
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 (
|
|
<button
|
|
className={`official-nav-item${active ? " is-active" : ""}`}
|
|
onClick={onClick}
|
|
type="button"
|
|
>
|
|
<Icon aria-hidden="true" size={18} strokeWidth={1.8} />
|
|
<span>{label}</span>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function ThreadMessage({ message }: { message: Message }) {
|
|
const text = textOfMessage(message)?.trim();
|
|
if (!text) return null;
|
|
const isUser = message.type === "human";
|
|
return (
|
|
<div className={`official-message official-message-${isUser ? "user" : "assistant"}`}>
|
|
{!isUser && (
|
|
<div className="official-assistant-mark">
|
|
<Sparkles aria-hidden="true" size={16} />
|
|
DeerFlow
|
|
</div>
|
|
)}
|
|
<p>{text}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ChatWorkspace({
|
|
agentId,
|
|
routeThreadId,
|
|
}: {
|
|
agentId: string | null;
|
|
routeThreadId: string;
|
|
}) {
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const [prompt, setPrompt] = useState("");
|
|
const [sendError, setSendError] = useState<string | null>(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<string>(() => uuid() as string);
|
|
const activeThreadId = isNewThread ? draftThreadId : routeThreadId;
|
|
const { agent } = useAgent(agentId, { enabled: !!agentId });
|
|
const [thread, sendMessage, isUploading] = useThreadStream({
|
|
threadId: isNewThread ? undefined : routeThreadId,
|
|
assistantId: agentId ?? undefined,
|
|
threadMetadata: projectConversationType
|
|
? myProjectThreadMetadata(projectConversationType)
|
|
: undefined,
|
|
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) => {
|
|
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<HTMLFormElement>) => {
|
|
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 (
|
|
<section className="official-chat-page">
|
|
<header className="official-page-header official-chat-header">
|
|
<div className="official-thread-title">
|
|
{title}
|
|
<ChevronDown aria-hidden="true" size={16} />
|
|
</div>
|
|
<div className="official-header-actions">
|
|
<button
|
|
className="official-outline-button"
|
|
onClick={() => navigate(`${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/scheduled-tasks`)}
|
|
type="button"
|
|
>
|
|
<CalendarClock aria-hidden="true" size={17} />
|
|
定时任务
|
|
</button>
|
|
<button aria-label="帮助" className="official-icon-button" type="button">
|
|
<CircleHelp aria-hidden="true" size={18} />
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
<div className={`official-chat-canvas${messages.length ? " has-history" : ""}`}>
|
|
{messages.length ? (
|
|
<div className="official-message-list">
|
|
{messages.map((message, index) => (
|
|
<ThreadMessage key={message.id ?? `${message.type}-${index}`} message={message} />
|
|
))}
|
|
{thread.isLoading && (
|
|
<div className="official-streaming-indicator">
|
|
<LoaderCircle aria-hidden="true" size={15} />
|
|
DeerFlow 正在回复…
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="official-chat-welcome">
|
|
<div className="official-logo-orb">
|
|
<Sparkles aria-hidden="true" size={28} />
|
|
</div>
|
|
<h1>{agent?.name ? `与 ${agent.name} 开始对话` : "今天想一起完成什么?"}</h1>
|
|
<p>直接连接当前 DeerFlow 后端;消息与历史记录会同步保存。</p>
|
|
{!agentId && (
|
|
<div className="official-suggestion-grid">
|
|
{[
|
|
["做一份市场调研", "比较行业趋势并形成要点"],
|
|
["规划项目", "把目标拆解成清晰步骤"],
|
|
["写一篇初稿", "从想法到结构化内容"],
|
|
].map(([suggestion, detail]) => (
|
|
<button key={suggestion} onClick={() => setPrompt(suggestion)} type="button">
|
|
<strong>{suggestion}</strong>
|
|
<span>{detail}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{(streamError || sendError) && (
|
|
<p className="official-inline-error" role="alert">
|
|
{sendError ?? streamError}
|
|
</p>
|
|
)}
|
|
<form className="official-composer-shell" onSubmit={handleSubmit}>
|
|
<div className="official-composer">
|
|
<textarea
|
|
aria-label="输入消息"
|
|
disabled={thread.isLoading || isUploading}
|
|
onChange={(event) => setPrompt(event.target.value)}
|
|
placeholder="给 DeerFlow 发送消息…"
|
|
rows={1}
|
|
value={prompt}
|
|
/>
|
|
<button
|
|
aria-label="发送"
|
|
disabled={!prompt.trim() || thread.isLoading || isUploading}
|
|
type="submit"
|
|
>
|
|
<SendHorizontal aria-hidden="true" size={18} />
|
|
</button>
|
|
</div>
|
|
<p>连接当前 DeerFlow 后端</p>
|
|
</form>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function AgentsPage({ openAgent }: { openAgent: (id: string) => void }) {
|
|
const navigate = useNavigate();
|
|
const [query, setQuery] = useState("");
|
|
const { agents, isLoading, error } = useAgents({ search: query });
|
|
|
|
return (
|
|
<section className="official-content-page">
|
|
<header className="official-page-header">
|
|
<div>
|
|
<p className="official-eyebrow">工作区</p>
|
|
<h1>智能体</h1>
|
|
<p>以下智能体直接来自当前 DeerFlow 后端。</p>
|
|
</div>
|
|
<button
|
|
className="official-primary-button"
|
|
onClick={() => navigate("/page/workspace/agents/new")}
|
|
type="button"
|
|
>
|
|
<PlusSquare aria-hidden="true" size={17} />
|
|
创建智能体
|
|
</button>
|
|
</header>
|
|
<div className="official-toolbar">
|
|
<label className="official-search">
|
|
<Search aria-hidden="true" size={17} />
|
|
<input
|
|
onChange={(event) => setQuery(event.target.value)}
|
|
placeholder="搜索智能体"
|
|
value={query}
|
|
/>
|
|
</label>
|
|
</div>
|
|
{error ? (
|
|
<p className="official-query-error" role="alert">
|
|
{readableError(error, "智能体列表加载失败。")}
|
|
</p>
|
|
) : isLoading ? (
|
|
<div className="official-loading-state"><LoaderCircle aria-hidden="true" size={18} /> 正在加载智能体…</div>
|
|
) : agents.length ? (
|
|
<div className="official-agent-grid">
|
|
{agents.map((agent, index) => (
|
|
<article className="official-agent-card" key={agent.id}>
|
|
<div className={`official-agent-avatar ${["violet", "sky", "amber"][index % 3]}`}>
|
|
{agent.name.slice(0, 2).toLocaleUpperCase()}
|
|
</div>
|
|
<h2>{agent.name}</h2>
|
|
<p>{agent.description || "暂无简介"}</p>
|
|
<button onClick={() => openAgent(agent.id)} type="button">
|
|
开始对话
|
|
</button>
|
|
</article>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="official-empty-panel">
|
|
<div className="official-empty-icon"><Bot aria-hidden="true" size={24} /></div>
|
|
<h2>暂无可用智能体</h2>
|
|
<p>可从“创建智能体”进入现有的完整配置页面。</p>
|
|
</div>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function ScheduledTasksPage({ selectedTaskId }: { selectedTaskId: string | null }) {
|
|
const navigate = useNavigate();
|
|
const { data: tasks = [], isLoading, error } = useScheduledTasks();
|
|
const { data: runs = [], isLoading: runsLoading, error: runsError } =
|
|
useScheduledTaskRuns(selectedTaskId);
|
|
const actions = useScheduledTaskActions();
|
|
const [actionTaskId, setActionTaskId] = useState<string | null>(null);
|
|
const [actionError, setActionError] = useState<string | null>(null);
|
|
const selectedTask = tasks.find((task) => task.task_id === selectedTaskId) ?? null;
|
|
|
|
const openTask = useCallback(
|
|
(taskId: string) => {
|
|
navigate(
|
|
`${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/scheduled-tasks/${encodeURIComponent(taskId)}`,
|
|
);
|
|
},
|
|
[navigate],
|
|
);
|
|
|
|
const closeTask = useCallback(() => {
|
|
navigate(`${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/scheduled-tasks`);
|
|
}, [navigate]);
|
|
|
|
const runTaskAction = useCallback(
|
|
async (task: ScheduledTask, action: "trigger" | "pause" | "resume") => {
|
|
setActionTaskId(task.task_id);
|
|
setActionError(null);
|
|
try {
|
|
if (action === "trigger") {
|
|
await actions.trigger.mutateAsync(task.task_id);
|
|
openTask(task.task_id);
|
|
} else if (action === "pause") {
|
|
await actions.pause.mutateAsync(task.task_id);
|
|
} else {
|
|
await actions.resume.mutateAsync(task.task_id);
|
|
}
|
|
} catch (actionFailure) {
|
|
setActionError(
|
|
readableError(
|
|
actionFailure,
|
|
action === "trigger" ? "任务触发失败。" : "任务状态更新失败。",
|
|
),
|
|
);
|
|
} finally {
|
|
setActionTaskId(null);
|
|
}
|
|
},
|
|
[actions.pause, actions.resume, actions.trigger, openTask],
|
|
);
|
|
|
|
const actionIsPending = (taskId: string) => actionTaskId === taskId;
|
|
|
|
return (
|
|
<section className="official-content-page">
|
|
<header className="official-page-header">
|
|
<div>
|
|
<p className="official-eyebrow">工作区</p>
|
|
<h1>定时任务</h1>
|
|
<p>任务、运行记录和控制操作均直接请求当前 DeerFlow 后端。</p>
|
|
</div>
|
|
<button
|
|
className="official-primary-button"
|
|
onClick={() => navigate("/page/workspace/scheduled/tasks")}
|
|
type="button"
|
|
>
|
|
<PlusSquare aria-hidden="true" size={17} />
|
|
新建任务
|
|
</button>
|
|
</header>
|
|
{actionError && (
|
|
<p className="official-query-error" role="alert">
|
|
{actionError}
|
|
</p>
|
|
)}
|
|
{selectedTask && (
|
|
<section className="official-task-detail" aria-label={`${selectedTask.name}详情`}>
|
|
<header>
|
|
<div>
|
|
<p className="official-eyebrow">任务详情</p>
|
|
<h2>{selectedTask.name}</h2>
|
|
<p>{selectedTask.description || selectedTask.prompt}</p>
|
|
</div>
|
|
<button
|
|
aria-label="关闭任务详情"
|
|
className="official-icon-button"
|
|
onClick={closeTask}
|
|
type="button"
|
|
>
|
|
<X aria-hidden="true" size={18} />
|
|
</button>
|
|
</header>
|
|
<div className="official-task-meta">
|
|
<span>计划:{selectedTask.schedule_text || selectedTask.cron_expr}</span>
|
|
<span>时区:{selectedTask.timezone}</span>
|
|
<span>下次执行:{formatTimestamp(selectedTask.next_run_at) || "未计划"}</span>
|
|
</div>
|
|
<div className="official-task-detail-actions">
|
|
<button
|
|
className="official-secondary-button"
|
|
disabled={actionIsPending(selectedTask.task_id)}
|
|
onClick={() => void runTaskAction(selectedTask, "trigger")}
|
|
type="button"
|
|
>
|
|
{actionIsPending(selectedTask.task_id) ? <LoaderCircle aria-hidden="true" size={15} /> : <Play aria-hidden="true" size={15} />}
|
|
立即执行
|
|
</button>
|
|
<button
|
|
className="official-secondary-button"
|
|
disabled={actionIsPending(selectedTask.task_id)}
|
|
onClick={() =>
|
|
void runTaskAction(
|
|
selectedTask,
|
|
selectedTask.enabled ? "pause" : "resume",
|
|
)
|
|
}
|
|
type="button"
|
|
>
|
|
{selectedTask.enabled ? <Pause aria-hidden="true" size={15} /> : <Play aria-hidden="true" size={15} />}
|
|
{selectedTask.enabled ? "暂停任务" : "恢复任务"}
|
|
</button>
|
|
</div>
|
|
<div className="official-run-list">
|
|
<h3>最近运行</h3>
|
|
{runsError ? (
|
|
<p className="official-query-error" role="alert">
|
|
{readableError(runsError, "运行记录加载失败。")}
|
|
</p>
|
|
) : runsLoading ? (
|
|
<p className="official-run-empty"><LoaderCircle aria-hidden="true" size={15} /> 正在加载运行记录…</p>
|
|
) : runs.length ? (
|
|
runs.slice(0, 8).map((run) => (
|
|
<button
|
|
className="official-run-row"
|
|
key={run.id}
|
|
onClick={() => navigate(`/page/workspace/scheduled/runs/${encodeURIComponent(run.id)}`)}
|
|
type="button"
|
|
>
|
|
<span className={`official-run-status is-${run.status}`}>{runStatusText(run.status)}</span>
|
|
<span>{formatTimestamp(run.started_at ?? run.scheduled_for) || "等待执行"}</span>
|
|
<span>{run.result?.title || run.error || "查看完整运行结果"}</span>
|
|
</button>
|
|
))
|
|
) : (
|
|
<p className="official-run-empty">该任务暂时没有运行记录。</p>
|
|
)}
|
|
</div>
|
|
</section>
|
|
)}
|
|
{error ? (
|
|
<p className="official-query-error" role="alert">
|
|
{readableError(error, "定时任务列表加载失败。")}
|
|
</p>
|
|
) : isLoading ? (
|
|
<div className="official-loading-state"><LoaderCircle aria-hidden="true" size={18} /> 正在加载定时任务…</div>
|
|
) : tasks.length ? (
|
|
<div className="official-task-list">
|
|
{tasks.map((task) => {
|
|
const status = statusForTask(task);
|
|
const pending = actionIsPending(task.task_id);
|
|
return (
|
|
<article
|
|
className={`official-task-card${selectedTaskId === task.task_id ? " is-selected" : ""}`}
|
|
key={task.task_id}
|
|
>
|
|
<button
|
|
className="official-task-card-main"
|
|
onClick={() => openTask(task.task_id)}
|
|
type="button"
|
|
>
|
|
<span className="official-task-icon"><Clock3 aria-hidden="true" size={18} /></span>
|
|
<span className="official-task-copy">
|
|
<strong>{task.name}</strong>
|
|
<small>{task.schedule_text || task.cron_expr}</small>
|
|
</span>
|
|
<span className={`official-task-status is-${status.className}`}>{status.label}</span>
|
|
<time>{formatTimestamp(task.next_run_at ?? task.updated_at)}</time>
|
|
</button>
|
|
<div className="official-task-card-actions">
|
|
<button
|
|
aria-label={`立即执行 ${task.name}`}
|
|
disabled={pending}
|
|
onClick={() => void runTaskAction(task, "trigger")}
|
|
title="立即执行"
|
|
type="button"
|
|
>
|
|
{pending ? <LoaderCircle aria-hidden="true" size={16} /> : <Play aria-hidden="true" size={16} />}
|
|
</button>
|
|
<button
|
|
aria-label={task.enabled ? `暂停 ${task.name}` : `恢复 ${task.name}`}
|
|
disabled={pending}
|
|
onClick={() => void runTaskAction(task, task.enabled ? "pause" : "resume")}
|
|
title={task.enabled ? "暂停任务" : "恢复任务"}
|
|
type="button"
|
|
>
|
|
{task.enabled ? <Pause aria-hidden="true" size={16} /> : <Play aria-hidden="true" size={16} />}
|
|
</button>
|
|
</div>
|
|
</article>
|
|
);
|
|
})}
|
|
</div>
|
|
) : (
|
|
<div className="official-empty-panel">
|
|
<div className="official-empty-icon"><Clock3 aria-hidden="true" size={24} /></div>
|
|
<h2>还没有定时任务</h2>
|
|
<p>创建一个任务,按天、周或自定义时间自动执行。</p>
|
|
<button
|
|
className="official-secondary-button"
|
|
onClick={() => navigate("/page/workspace/scheduled/tasks")}
|
|
type="button"
|
|
>
|
|
创建定时任务
|
|
</button>
|
|
</div>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function SettingsPage() {
|
|
const { data: appearance, isLoading: appearanceLoading, error: appearanceError } =
|
|
useAppearanceSettings();
|
|
const { data: citation, isLoading: citationLoading, error: citationError } =
|
|
useCitationDisplaySettings();
|
|
const settings = [
|
|
appearance && {
|
|
label: "默认外观",
|
|
detail: `主题:${appearance.default_theme};布局:${appearance.default_layout_mode}`,
|
|
},
|
|
appearance && {
|
|
label: "默认展示方式",
|
|
detail: `智能体:${appearance.default_agent_view};技能:${appearance.default_skill_view}`,
|
|
},
|
|
citation && {
|
|
label: "引用展示",
|
|
detail: citation.reference_mode_enabled ? "已启用引用模式" : "未启用引用模式",
|
|
},
|
|
].filter(Boolean) as Array<{ label: string; detail: string }>;
|
|
const isLoading = appearanceLoading || citationLoading;
|
|
|
|
return (
|
|
<section className="official-content-page">
|
|
<header className="official-page-header">
|
|
<div>
|
|
<p className="official-eyebrow">工作区</p>
|
|
<h1>设置</h1>
|
|
<p>只展示已由当前 DeerFlow 后端提供的配置,不使用模拟值。</p>
|
|
</div>
|
|
</header>
|
|
{isLoading && !settings.length ? (
|
|
<div className="official-loading-state"><LoaderCircle aria-hidden="true" size={18} /> 正在加载系统配置…</div>
|
|
) : settings.length ? (
|
|
<div className="official-settings-list">
|
|
{settings.map((setting) => (
|
|
<div className="official-setting-row" key={setting.label}>
|
|
<strong>{setting.label}</strong>
|
|
<small>{setting.detail}</small>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="official-empty-panel">
|
|
<div className="official-empty-icon"><Settings2 aria-hidden="true" size={24} /></div>
|
|
<h2>暂时没有可读取的系统配置</h2>
|
|
<p>不会用静态数据替代未返回的后端配置。</p>
|
|
</div>
|
|
)}
|
|
{(appearanceError || citationError) && (
|
|
<p className="official-query-error" role="alert">
|
|
部分系统配置不可读取:{readableError(appearanceError ?? citationError, "请检查登录权限。")}
|
|
</p>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Vite-native official-style workspace. It reuses the current frontend's
|
|
* backend clients and stream adapter; only reference-only UI has local state.
|
|
*/
|
|
export default function OfficialDeerFlowEntry() {
|
|
const location = useLocation();
|
|
const navigate = useNavigate();
|
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
|
const page = pageFromPath(location.pathname);
|
|
const chatRoute = useMemo(
|
|
() => parseChatRoute(location.pathname),
|
|
[location.pathname],
|
|
);
|
|
const scheduledTaskId = useMemo(
|
|
() => parseScheduledTaskId(location.pathname),
|
|
[location.pathname],
|
|
);
|
|
const projectId = useMemo(
|
|
() => parseProjectId(location.pathname),
|
|
[location.pathname],
|
|
);
|
|
const { data: threads = [], isLoading: threadsLoading, error: threadsError } =
|
|
useThreads({
|
|
limit: 20,
|
|
sortBy: "updated_at",
|
|
sortOrder: "desc",
|
|
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: Globe, label: "Web 应用", path: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/web-app`, pages: ["web-apps"] },
|
|
{ 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;
|
|
|
|
return (
|
|
<main className={`official-deerflow${sidebarCollapsed ? " sidebar-collapsed" : ""}`}>
|
|
<aside className="official-sidebar">
|
|
<div className="official-brand-row">
|
|
<div className="official-brand">DeerFlow</div>
|
|
<button
|
|
aria-label="折叠侧栏"
|
|
className="official-collapse-button"
|
|
onClick={() => setSidebarCollapsed((value) => !value)}
|
|
type="button"
|
|
>
|
|
<PanelLeftClose aria-hidden="true" size={17} />
|
|
</button>
|
|
</div>
|
|
<nav aria-label="官方页面导航" className="official-nav">
|
|
{nav.map((item) => (
|
|
<OfficialNavItem
|
|
active={item.pages.some((candidate) => candidate === page)}
|
|
icon={item.icon}
|
|
key={item.label}
|
|
label={item.label}
|
|
onClick={() => go(item.path)}
|
|
/>
|
|
))}
|
|
</nav>
|
|
<div className="official-recent-section">
|
|
<p>最近对话</p>
|
|
{threadsLoading ? (
|
|
<div className="official-sidebar-loading"><LoaderCircle aria-hidden="true" size={14} /> 加载中…</div>
|
|
) : threadsError ? (
|
|
<p className="official-sidebar-error">对话列表加载失败</p>
|
|
) : threads.length ? (
|
|
threads.map((chat) => {
|
|
const projectType = myProjectConversationTypeOfThread(chat);
|
|
return (
|
|
<button
|
|
className={`official-recent-chat${chatRoute.threadId === chat.thread_id ? " is-active" : ""}`}
|
|
key={chat.thread_id}
|
|
onClick={() => {
|
|
const projectId = chat.metadata?.analysis_project_id;
|
|
if (typeof projectId === "string" && projectId) {
|
|
go(`${projectBasePath}/groups/${encodeURIComponent(projectId)}/chats/${encodeURIComponent(chat.thread_id)}`);
|
|
return;
|
|
}
|
|
const agentId = typeof chat.context?.agent_id === "string"
|
|
? chat.context.agent_id
|
|
: typeof chat.metadata?.agent_id === "string"
|
|
? chat.metadata.agent_id
|
|
: null;
|
|
go(
|
|
agentId
|
|
? `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/agents/${encodeURIComponent(agentId)}/chats/${encodeURIComponent(chat.thread_id)}`
|
|
: `${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/chats/${encodeURIComponent(chat.thread_id)}`,
|
|
);
|
|
}}
|
|
type="button"
|
|
>
|
|
{projectType ? (
|
|
<MyProjectThreadIcon className="size-[15px]" type={projectType} />
|
|
) : (
|
|
<MessageSquareText aria-hidden="true" size={15} />
|
|
)}
|
|
<span className="official-recent-chat-copy">
|
|
<span className="official-recent-chat-title">
|
|
{titleOfThread(chat, "未命名对话")}
|
|
</span>
|
|
{projectType && (
|
|
<MyProjectThreadBadge type={projectType} />
|
|
)}
|
|
</span>
|
|
<small>{formatTimestamp(chat.updated_at)}</small>
|
|
</button>
|
|
);
|
|
})
|
|
) : (
|
|
<p className="official-sidebar-empty">暂无历史对话</p>
|
|
)}
|
|
</div>
|
|
<div className="official-sidebar-footer">
|
|
<button onClick={() => go(`${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/settings`)} type="button">
|
|
<Settings2 aria-hidden="true" size={18} />
|
|
<span>设置和更多</span>
|
|
<ChevronDown aria-hidden="true" size={15} />
|
|
</button>
|
|
<div className="official-user-row">
|
|
<span className="official-user-avatar"><UserRound aria-hidden="true" size={15} /></span>
|
|
<span>{getAccountDisplayName()}</span>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
<div className="official-main-panel">
|
|
{page === "chat" && (
|
|
<ChatWorkspace
|
|
agentId={null}
|
|
key={location.key}
|
|
routeThreadId={chatRoute.threadId}
|
|
/>
|
|
)}
|
|
{page === "projects" && (
|
|
(location.pathname === `${projectBasePath}/groups` || location.pathname.startsWith(`${projectBasePath}/groups/`)) ? (
|
|
<SidebarProvider className="h-full min-h-0">
|
|
<Routes>
|
|
<Route path="workspace/projects/groups/*" element={<AnalysisProjectsPage basePath={`${projectBasePath}/groups`} listPath={`${projectBasePath}?type=qa`} mode="qa" showSidebarTrigger={false} />} />
|
|
</Routes>
|
|
</SidebarProvider>
|
|
) : projectId ? (
|
|
<StrategySelectionPage
|
|
key={projectId}
|
|
projectBasePath={projectBasePath}
|
|
projectId={projectId}
|
|
/>
|
|
) : (
|
|
<StrategyProjectsPage basePath={projectBasePath} />
|
|
)
|
|
)}
|
|
{page === "web-apps" && <OfficialWebAppsPage />}
|
|
{page === "agents" && (
|
|
<AgentsPage
|
|
openAgent={(agentId) =>
|
|
go(`${OFFICIAL_DEERFLOW_ROUTE_PREFIX}/workspace/agents/${encodeURIComponent(agentId)}/chats/new`)
|
|
}
|
|
/>
|
|
)}
|
|
{page === "agent-chat" && (
|
|
<ChatWorkspace
|
|
agentId={chatRoute.agentId}
|
|
key={location.key}
|
|
routeThreadId={chatRoute.threadId}
|
|
/>
|
|
)}
|
|
{page === "scheduled" && <ScheduledTasksPage selectedTaskId={scheduledTaskId} />}
|
|
{page === "settings" && <SettingsPage />}
|
|
</div>
|
|
<Toaster position="top-center" />
|
|
</main>
|
|
);
|
|
}
|