Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1
解决 artifact 预览组件冲突:mermaid 渲染(dhk-1) 与 markdown 锚点跳转(master) 取并集;package-lock.json 按 master 删除(项目用 pnpm)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
b2132dd668
104 files changed
+8775
-12038
No files matched your search
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Download,
|
||||
Edit3,
|
||||
@@ -71,6 +72,7 @@ const emptyForm = {
|
||||
};
|
||||
|
||||
export default function AdminScheduledTasksPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { theme } = useTheme();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
@@ -178,8 +180,15 @@ export default function AdminScheduledTasksPage() {
|
||||
};
|
||||
|
||||
const triggerTask = async (task: ScheduledTask) => {
|
||||
setSelectedTaskId(task.task_id);
|
||||
try {
|
||||
await actions.trigger.mutateAsync(task.task_id);
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["scheduled-tasks", "admin", task.task_id, "runs"],
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["scheduled-tasks", "admin", "list"],
|
||||
});
|
||||
toast.success("已触发执行");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "触发失败");
|
||||
@@ -208,23 +217,24 @@ export default function AdminScheduledTasksPage() {
|
||||
<WorkspaceContainer>
|
||||
<WorkspaceHeader />
|
||||
<WorkspaceBody>
|
||||
<div className="grid size-full min-h-0 grid-cols-1 gap-4 p-4 xl:grid-cols-[460px_minmax(0,1fr)]">
|
||||
<Card className="flex min-h-0 flex-col">
|
||||
<div className="grid size-full min-h-0 grid-cols-1 grid-rows-[minmax(0,1fr)_minmax(0,1fr)] gap-4 p-4 xl:grid-cols-[460px_minmax(0,1fr)] xl:grid-rows-1">
|
||||
<Card className="flex min-h-0 min-w-0 flex-col">
|
||||
<CardHeader className="gap-3">
|
||||
<div>
|
||||
<div className="min-w-0">
|
||||
<CardTitle>定时任务管理</CardTitle>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
管理员视图:查看、编辑、删除全部用户的定时任务
|
||||
</p>
|
||||
</div>
|
||||
<form
|
||||
className="flex gap-2"
|
||||
className="flex min-w-0 gap-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
setSearch(searchInput.trim());
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
className="min-w-0"
|
||||
value={searchInput}
|
||||
onChange={(event) => setSearchInput(event.target.value)}
|
||||
placeholder="按任务名称搜索"
|
||||
@@ -268,7 +278,7 @@ export default function AdminScheduledTasksPage() {
|
||||
onClick={() => setSelectedTaskId(task.task_id)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium">
|
||||
{task.name}
|
||||
</div>
|
||||
@@ -348,10 +358,10 @@ export default function AdminScheduledTasksPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="flex min-h-0 flex-col">
|
||||
<Card className="flex min-h-0 min-w-0 flex-col">
|
||||
<CardHeader className="gap-2">
|
||||
<CardTitle>执行记录</CardTitle>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<p className="text-muted-foreground min-w-0 truncate text-sm">
|
||||
{selectedTask ? selectedTask.name : "请选择左侧任务"}
|
||||
</p>
|
||||
</CardHeader>
|
||||
@@ -379,11 +389,11 @@ export default function AdminScheduledTasksPage() {
|
||||
className="rounded-md border p-4 transition-colors hover:bg-accent/40"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium">
|
||||
{run.result?.title || formatDate(run.scheduled_for)}
|
||||
</div>
|
||||
<div className="text-muted-foreground mt-1 text-xs">
|
||||
<div className="text-muted-foreground mt-1 break-words text-xs">
|
||||
计划:{formatDate(run.scheduled_for)} · 开始:
|
||||
{formatDate(run.started_at)}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2, MessageSquareIcon, SearchIcon, UserIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -109,11 +110,13 @@ function MemoryBucketView({ bucket }: { bucket: AdminMemoryBucket }) {
|
||||
}
|
||||
|
||||
export default function AdminUserActivityPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const { data: users, isLoading: usersLoading } = useAllUsers(true);
|
||||
const [userId, setUserId] = useState<string | null>(null);
|
||||
const [threadId, setThreadId] = useState<string | null>(null);
|
||||
const [userQuery, setUserQuery] = useState("");
|
||||
const [tab, setTab] = useState<"qa" | "memory">("qa");
|
||||
const targetUserId = searchParams.get("user");
|
||||
|
||||
const { data: threads, isLoading: threadsLoading } = useUserThreads(userId);
|
||||
const { data: messages, isLoading: messagesLoading } = useUserThreadMessages(
|
||||
@@ -122,6 +125,14 @@ export default function AdminUserActivityPage() {
|
||||
);
|
||||
const { data: memory, isLoading: memoryLoading } = useUserMemory(userId);
|
||||
|
||||
useEffect(() => {
|
||||
if (targetUserId) {
|
||||
setUserId(targetUserId);
|
||||
setThreadId(null);
|
||||
setTab("qa");
|
||||
}
|
||||
}, [targetUserId]);
|
||||
|
||||
const filteredUsers = useMemo(() => {
|
||||
const needle = userQuery.trim().toLowerCase();
|
||||
const list = users ?? [];
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,204 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeft, Save, Search, ShieldOff, SlidersHorizontal, X } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import type { UserListItem } from "@/core/auth/api";
|
||||
import { useAllUsers } from "@/core/auth/hooks";
|
||||
import {
|
||||
useLeaderboardSettings,
|
||||
useUpdateLeaderboardSettings,
|
||||
} from "@/core/system-settings/hooks";
|
||||
|
||||
function userLabel(user?: UserListItem) {
|
||||
return user?.email || user?.id || "";
|
||||
}
|
||||
|
||||
export default function AdminUserLeaderboardSettingsPage() {
|
||||
const navigate = useNavigate();
|
||||
const { data: users = [] } = useAllUsers(true);
|
||||
const { data: settings, isLoading } = useLeaderboardSettings();
|
||||
const updateSettings = useUpdateLeaderboardSettings();
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const excludedIds = settings?.exclude_user_ids ?? [];
|
||||
const selectedUsers = useMemo(
|
||||
() => excludedIds.map((id) => users.find((user) => user.id === id) ?? ({ id, email: id, system_role: "user" } as UserListItem)),
|
||||
[excludedIds, users],
|
||||
);
|
||||
const candidates = useMemo(() => {
|
||||
const needle = query.trim().toLowerCase();
|
||||
if (!needle) return [];
|
||||
return users
|
||||
.filter((user) => !excludedIds.includes(user.id))
|
||||
.filter((user) => `${user.email} ${user.id}`.toLowerCase().includes(needle))
|
||||
.slice(0, 8);
|
||||
}, [excludedIds, query, users]);
|
||||
|
||||
const patch = async (body: Record<string, unknown>) => {
|
||||
try {
|
||||
await updateSettings.mutateAsync(body);
|
||||
toast.success("排行榜设置已保存");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "保存失败");
|
||||
}
|
||||
};
|
||||
|
||||
const addUser = (userId: string) => {
|
||||
void patch({ exclude_user_ids: [...excludedIds, userId] });
|
||||
setQuery("");
|
||||
};
|
||||
|
||||
const removeUser = (userId: string) => {
|
||||
void patch({ exclude_user_ids: excludedIds.filter((id) => id !== userId) });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex size-full min-h-0 bg-[#FFF7F7] text-[#231815] dark:bg-[#19090A] dark:text-white">
|
||||
<main className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="border-b border-[#F1D6D6] bg-white px-6 py-5 dark:border-[#462123] dark:bg-[#230D0F]">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="mb-2 flex items-center gap-2 text-sm text-[#9F343A]">
|
||||
<SlidersHorizontal className="size-4" />
|
||||
管理员设置
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold">排行榜设置</h1>
|
||||
<p className="mt-1 text-sm text-[#7A5A5A] dark:text-[#D8B8B8]">
|
||||
这里的规则会影响用户排行榜、趋势、技能、模型、问题与个人详情统计。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => navigate("/page/strategy/admin/user-leaderboard")}>
|
||||
<ArrowLeft className="mr-1 size-4" />
|
||||
返回排行榜
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="mx-auto max-w-6xl space-y-5 p-6">
|
||||
<section className="rounded-lg border border-[#F1D6D6] bg-white p-5 shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
|
||||
<div className="mb-4 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 font-semibold">
|
||||
<ShieldOff className="size-4 text-[#C1121F]" />
|
||||
统计排除名单
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-[#7A5A5A] dark:text-[#D8B8B8]">
|
||||
名单中的账号不会进入排行榜、趋势、问题、模型和技能统计。
|
||||
</p>
|
||||
</div>
|
||||
{excludedIds.length > 0 && (
|
||||
<Button size="sm" variant="outline" onClick={() => void patch({ exclude_user_ids: [] })}>
|
||||
清空名单
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[340px_minmax(0,1fr)]">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[#A87575]" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="搜索账号邮箱或 ID"
|
||||
className="h-10 border-[#F1D6D6] bg-white pl-9"
|
||||
/>
|
||||
{query.trim() && candidates.length > 0 && (
|
||||
<div className="absolute z-20 mt-2 max-h-72 w-full overflow-hidden rounded-lg border border-[#F1D6D6] bg-white shadow-lg dark:border-[#462123] dark:bg-[#230D0F]">
|
||||
<ScrollArea className="max-h-72">
|
||||
{candidates.map((user) => (
|
||||
<button
|
||||
key={user.id}
|
||||
type="button"
|
||||
onClick={() => addUser(user.id)}
|
||||
className="flex w-full items-center justify-between gap-3 border-b border-[#F8EAEA] px-3 py-2 text-left text-sm last:border-b-0 hover:bg-[#FFF1F1] dark:border-[#462123] dark:hover:bg-[#361315]"
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate font-medium">{user.email}</span>
|
||||
<span className="block truncate text-xs text-[#8B6B6B]">{user.id}</span>
|
||||
</span>
|
||||
<Badge variant="outline">{user.system_role === "admin" ? "管理员" : "用户"}</Badge>
|
||||
</button>
|
||||
))}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-h-10 rounded-lg bg-[#FFF7F7] p-3 dark:bg-[#19090A]">
|
||||
{selectedUsers.length === 0 ? (
|
||||
<span className="text-sm text-[#8B6B6B]">暂无排除账号</span>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedUsers.map((user) => (
|
||||
<span
|
||||
key={user.id}
|
||||
className="inline-flex max-w-[280px] items-center gap-1.5 rounded-full border border-[#F1D6D6] bg-white px-3 py-1.5 text-xs text-[#7A1F25] shadow-sm dark:border-[#462123] dark:bg-[#230D0F] dark:text-[#F2B8BB]"
|
||||
title={userLabel(user)}
|
||||
>
|
||||
<span className="truncate">{userLabel(user)}</span>
|
||||
<button type="button" onClick={() => removeUser(user.id)} aria-label={`移除 ${userLabel(user)}`}>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-[#F1D6D6] bg-white p-5 shadow-sm dark:border-[#462123] dark:bg-[#230D0F]">
|
||||
<div className="mb-4 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 font-semibold">
|
||||
<Save className="size-4 text-[#C1121F]" />
|
||||
统计规则
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-[#7A5A5A] dark:text-[#D8B8B8]">
|
||||
关闭某项后,该类数据会从榜单、趋势、问题列表和个人统计中移除。
|
||||
</p>
|
||||
</div>
|
||||
{isLoading && <Badge variant="outline">加载中</Badge>}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{[
|
||||
["include_scheduled", "统计定时任务", "关闭后,定时任务产生的运行、问答、Token、模型调用、最新问题都不统计。"],
|
||||
["include_admins", "统计管理员账号", "关闭后,管理员账号不参与排行和整体指标。"],
|
||||
["include_failed", "统计失败运行", "关闭后,失败运行和失败模型调用不进入榜单。"],
|
||||
["cleanup_system_questions", "隐藏系统标签问题", "开启后,<uploaded_files> 等附件/系统标签不会出现在问题列表。"],
|
||||
].map(([key, title, desc]) => (
|
||||
<label
|
||||
key={key}
|
||||
className="flex min-h-28 cursor-pointer items-start justify-between gap-4 rounded-lg border border-[#F1D6D6] bg-[#FFF7F7] p-4 dark:border-[#462123] dark:bg-[#19090A]"
|
||||
>
|
||||
<span>
|
||||
<span className="block font-medium">{title}</span>
|
||||
<span className="mt-1 block text-sm leading-6 text-[#7A5A5A] dark:text-[#D8B8B8]">{desc}</span>
|
||||
</span>
|
||||
<Switch
|
||||
checked={Boolean(settings?.[key as keyof typeof settings])}
|
||||
disabled={!settings || updateSettings.isPending}
|
||||
onCheckedChange={(checked) => void patch({ [key]: checked })}
|
||||
aria-label={title}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -250,6 +250,7 @@ export default function AgentChatPage() {
|
||||
className="w-full"
|
||||
isNewThread={isLandingState}
|
||||
threadId={threadId}
|
||||
assistantId={agent_id}
|
||||
autoFocus={isLandingState}
|
||||
status={
|
||||
thread.error
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||
import { usePromptPrefixSettings } from "@/core/system-settings/hooks";
|
||||
import { useUserPreferencesQuery } from "@/core/user-preferences";
|
||||
import { ArtifactTrigger, useArtifacts } from "@/components/workspace/artifacts";
|
||||
import {
|
||||
ChatBox,
|
||||
@@ -50,6 +51,11 @@ export default function ChatPage() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { tokenUsageEnabled } = useModels();
|
||||
const { data: promptPrefixSettings } = usePromptPrefixSettings();
|
||||
// Personal opt-out: when set, hide the admin prompt-prefix switch entirely and
|
||||
// never apply the prefix — the user uses only their own prompts.
|
||||
const { data: userPreferences, isLoading: preferencesLoading } =
|
||||
useUserPreferencesQuery();
|
||||
const hideAdminPrompt = userPreferences?.hide_admin_prompt_prefix === true;
|
||||
const [promptPrefixOn, setPromptPrefixOn] = useState(false);
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
||||
const { agents: selectorAgents, isLoading: selectorLoading } =
|
||||
@@ -159,6 +165,8 @@ export default function ChatPage() {
|
||||
isNewThread &&
|
||||
!hasStartedConversation &&
|
||||
promptPrefixOn &&
|
||||
!preferencesLoading &&
|
||||
!hideAdminPrompt &&
|
||||
promptPrefixSettings?.enabled === true;
|
||||
setHasStartedConversation(true);
|
||||
window.dispatchEvent(
|
||||
@@ -189,6 +197,8 @@ export default function ChatPage() {
|
||||
hasStartedConversation,
|
||||
promptPrefixOn,
|
||||
promptPrefixSettings,
|
||||
hideAdminPrompt,
|
||||
preferencesLoading,
|
||||
selectedAgentId,
|
||||
router,
|
||||
settings.context.writing_mode,
|
||||
@@ -217,6 +227,9 @@ export default function ChatPage() {
|
||||
|
||||
const promptPrefixProp = useMemo(() => {
|
||||
if (!promptPrefixSettings?.enabled) return undefined;
|
||||
if (preferencesLoading) return undefined;
|
||||
// Personal opt-out hides the admin prompt-prefix switch entirely.
|
||||
if (hideAdminPrompt) return undefined;
|
||||
// The prefix toggle is offered only on the opening turn of a new thread.
|
||||
if (!isNewThread || hasStartedConversation) return undefined;
|
||||
return {
|
||||
@@ -224,7 +237,14 @@ export default function ChatPage() {
|
||||
on: promptPrefixOn,
|
||||
onToggle: () => setPromptPrefixOn((v) => !v),
|
||||
};
|
||||
}, [promptPrefixSettings, isNewThread, hasStartedConversation, promptPrefixOn]);
|
||||
}, [
|
||||
promptPrefixSettings,
|
||||
hideAdminPrompt,
|
||||
preferencesLoading,
|
||||
isNewThread,
|
||||
hasStartedConversation,
|
||||
promptPrefixOn,
|
||||
]);
|
||||
|
||||
const messageListPaddingBottom = showFollowups
|
||||
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
|
||||
|
||||
@@ -310,7 +310,7 @@ const errorToastDarkOptions = {
|
||||
<label className="text-muted-foreground text-xs dark:text-white">用户名</label>
|
||||
<Input
|
||||
value={user}
|
||||
placeholder="按用户名匹配(@ 前部分)"
|
||||
placeholder="按用户名模糊搜索(@ 前部分)"
|
||||
onChange={(e) => {
|
||||
setUser(e.target.value);
|
||||
setPage(0);
|
||||
|
||||
@@ -19,6 +19,11 @@ import {
|
||||
} from "@/components/ai-elements/prompt-input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "@/components/ui/resizable";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -361,8 +366,19 @@ export default function NewAgentPage() {
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<main className="grid min-h-0 flex-1 grid-cols-1 md:grid-cols-[minmax(0,1fr)_420px]">
|
||||
<section className="flex min-h-0 flex-col border-r">
|
||||
<ResizablePanelGroup
|
||||
id="new-agent-config-panels"
|
||||
orientation="horizontal"
|
||||
defaultLayout={{ chat: 62, config: 38 }}
|
||||
className="min-h-0 flex-1"
|
||||
>
|
||||
<ResizablePanel
|
||||
id="chat"
|
||||
defaultSize={62}
|
||||
minSize={40}
|
||||
className="min-h-0"
|
||||
>
|
||||
<section className="flex size-full min-h-0 flex-col">
|
||||
<div className="min-h-0 flex-1">
|
||||
<MessageList
|
||||
className="size-full"
|
||||
@@ -428,8 +444,18 @@ export default function NewAgentPage() {
|
||||
</PromptInput>
|
||||
</div>
|
||||
</section>
|
||||
</ResizablePanel>
|
||||
|
||||
<aside className="bg-background min-h-0 overflow-y-auto">
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel
|
||||
id="config"
|
||||
defaultSize={38}
|
||||
minSize={24}
|
||||
maxSize={60}
|
||||
className="min-h-0"
|
||||
>
|
||||
<aside className="bg-background size-full min-h-0 overflow-y-auto">
|
||||
<div className="space-y-5 p-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold">智能体配置</h2>
|
||||
@@ -606,7 +632,8 @@ export default function NewAgentPage() {
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
</ArtifactsProvider>
|
||||
</ThreadContext.Provider>
|
||||
|
||||
@@ -3,14 +3,18 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Check,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Download,
|
||||
FileText,
|
||||
List,
|
||||
Loader2,
|
||||
Pencil,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { PointerEvent as ReactPointerEvent } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -35,6 +39,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
||||
import { HtmlPreviewFrame } from "@/components/html-preview-frame";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
|
||||
import { reasoningPlugins } from "@/core/streamdown";
|
||||
@@ -253,6 +258,11 @@ function stripTimestampSuffix(title: string): string {
|
||||
}
|
||||
|
||||
const BODY_TAB_VALUE = "__body__";
|
||||
const HTML_TAB_VALUE = "__html__";
|
||||
const RUN_LIST_COLLAPSED_WIDTH = 56;
|
||||
const RUN_LIST_DEFAULT_WIDTH = 380;
|
||||
const RUN_LIST_MIN_WIDTH = 280;
|
||||
const RUN_LIST_MAX_WIDTH = 560;
|
||||
|
||||
/**
|
||||
* Public, unauthenticated scheduled-task viewer.
|
||||
@@ -269,6 +279,9 @@ export default function PublicScheduledTaskPage() {
|
||||
const { taskId } = useParams<{ taskId: string }>();
|
||||
const [selectedRunId, setSelectedRunId] = useState<string | null>(null);
|
||||
const [pendingDeleteRun, setPendingDeleteRun] = useState<{ id: string; title: string } | null>(null);
|
||||
const [runListExpanded, setRunListExpanded] = useState(false);
|
||||
const [runListWidth, setRunListWidth] = useState(RUN_LIST_DEFAULT_WIDTH);
|
||||
const dragStateRef = useRef<{ startX: number; startWidth: number } | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading, isError, error } = useQuery({
|
||||
@@ -332,6 +345,58 @@ export default function PublicScheduledTaskPage() {
|
||||
}
|
||||
}, [runs, selectedRunId]);
|
||||
|
||||
const task = data?.task;
|
||||
const visibleRunListWidth = runListExpanded ? runListWidth : RUN_LIST_COLLAPSED_WIDTH;
|
||||
|
||||
const expandRunList = useCallback(() => {
|
||||
setRunListExpanded(true);
|
||||
setRunListWidth((width) =>
|
||||
Math.min(RUN_LIST_MAX_WIDTH, Math.max(RUN_LIST_MIN_WIDTH, width)),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const collapseRunList = useCallback(() => {
|
||||
setRunListExpanded(false);
|
||||
}, []);
|
||||
|
||||
const handleRunListDragStart = useCallback(
|
||||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
dragStateRef.current = {
|
||||
startX: event.clientX,
|
||||
startWidth: visibleRunListWidth,
|
||||
};
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
|
||||
const handlePointerMove = (moveEvent: PointerEvent) => {
|
||||
const drag = dragStateRef.current;
|
||||
if (!drag) return;
|
||||
const nextWidth = drag.startWidth + moveEvent.clientX - drag.startX;
|
||||
if (nextWidth <= RUN_LIST_COLLAPSED_WIDTH + 28) {
|
||||
setRunListExpanded(false);
|
||||
return;
|
||||
}
|
||||
setRunListExpanded(true);
|
||||
setRunListWidth(
|
||||
Math.min(RUN_LIST_MAX_WIDTH, Math.max(RUN_LIST_MIN_WIDTH, nextWidth)),
|
||||
);
|
||||
};
|
||||
|
||||
const handlePointerUp = () => {
|
||||
dragStateRef.current = null;
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
window.removeEventListener("pointermove", handlePointerMove);
|
||||
window.removeEventListener("pointerup", handlePointerUp);
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", handlePointerMove);
|
||||
window.addEventListener("pointerup", handlePointerUp);
|
||||
},
|
||||
[visibleRunListWidth],
|
||||
);
|
||||
|
||||
if (!taskId) {
|
||||
return (
|
||||
<div className="text-muted-foreground flex h-screen items-center justify-center text-sm">
|
||||
@@ -357,12 +422,15 @@ export default function PublicScheduledTaskPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const task = data?.task;
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={150}>
|
||||
<div className="bg-muted/20 grid h-screen min-h-0 grid-cols-1 gap-4 p-4 xl:grid-cols-[380px_minmax(0,1fr)]">
|
||||
<Card className="flex min-h-0 flex-col">
|
||||
<div className="bg-muted/20 flex h-screen min-h-0 overflow-hidden p-4">
|
||||
<div
|
||||
className="relative flex min-h-0 shrink-0 transition-[width] duration-200 ease-out"
|
||||
style={{ width: visibleRunListWidth }}
|
||||
>
|
||||
{runListExpanded ? (
|
||||
<Card className="flex min-h-0 w-full flex-col overflow-hidden">
|
||||
<CardHeader className="gap-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<Tooltip>
|
||||
@@ -377,9 +445,21 @@ export default function PublicScheduledTaskPage() {
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
<Badge variant={task?.enabled ? "default" : "secondary"} className="shrink-0">
|
||||
{task?.enabled ? "启用" : "暂停"}
|
||||
</Badge>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Badge variant={task?.enabled ? "default" : "secondary"}>
|
||||
{task?.enabled ? "启用" : "暂停"}
|
||||
</Badge>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-primary size-7"
|
||||
onClick={collapseRunList}
|
||||
title="收起执行记录列表"
|
||||
aria-label="收起执行记录列表"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{task?.description && task.description.trim() && (
|
||||
<p className="text-muted-foreground line-clamp-3 text-xs leading-relaxed break-all whitespace-pre-wrap">
|
||||
@@ -481,9 +561,36 @@ export default function PublicScheduledTaskPage() {
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="flex min-h-0 w-full flex-col items-center overflow-hidden py-3">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-primary"
|
||||
onClick={expandRunList}
|
||||
title="展开执行记录列表"
|
||||
aria-label="展开执行记录列表"
|
||||
>
|
||||
<List className="size-4" />
|
||||
</Button>
|
||||
</Card>
|
||||
)}
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
title="拖拽调整列表宽度"
|
||||
className="group absolute top-0 -right-3 z-20 flex h-full w-6 cursor-col-resize items-center justify-center"
|
||||
onPointerDown={handleRunListDragStart}
|
||||
>
|
||||
<div className="bg-border group-hover:bg-primary/70 h-full w-px transition-colors" />
|
||||
<div className="bg-background text-muted-foreground group-hover:text-primary absolute top-1/2 flex size-5 -translate-y-1/2 items-center justify-center rounded-full border shadow-sm transition-colors">
|
||||
<ChevronRight className="size-3" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="flex min-h-0 flex-col">
|
||||
<Card className="ml-4 flex min-h-0 flex-1 flex-col">
|
||||
{selectedRun ? (
|
||||
<RunDetailPane key={selectedRun.id} run={selectedRun} taskId={taskId} />
|
||||
) : (
|
||||
@@ -546,12 +653,14 @@ export default function PublicScheduledTaskPage() {
|
||||
}
|
||||
|
||||
type ContentTab = {
|
||||
/** Stable tab identifier — `__body__` for inline content, `file-<index>` for files. */
|
||||
/** Stable tab identifier — `__html__` for the HTML preview, `__body__` for
|
||||
* inline content, `file-<index>` for files. */
|
||||
value: string;
|
||||
/** Short label rendered in the TabsTrigger. */
|
||||
label: string;
|
||||
/** Distinguishes body vs MD attachment so the editor knows which PUT to call. */
|
||||
kind: "body" | "file";
|
||||
/** Distinguishes the HTML preview vs body vs MD attachment so the renderer /
|
||||
* editor knows what to show and which PUT to call. */
|
||||
kind: "body" | "file" | "html";
|
||||
/** File index (when `kind === "file"`). */
|
||||
fileIndex?: number;
|
||||
/** Filename for downloads (defaults to the run title for body). */
|
||||
@@ -592,17 +701,27 @@ function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) {
|
||||
[run.result?.files],
|
||||
);
|
||||
|
||||
// Tabs: every MD file first (priority per spec), then the inline body.
|
||||
// Tabs: HTML preview first (when present), then every MD file, then the
|
||||
// inline body. For an HTML run that also produced a Markdown file, this keeps
|
||||
// the page preview AND surfaces the .md instead of hiding it. The generated
|
||||
// HTML document itself is never listed as a downloadable "file" tab — it has
|
||||
// its own preview.
|
||||
const tabs: ContentTab[] = useMemo(() => {
|
||||
const list: ContentTab[] = markdownFiles.map((file, idx) => ({
|
||||
value: `file-${file.index}`,
|
||||
// Show the actual filename without the `.md` suffix — the user explicitly
|
||||
// asked for "no '文件' label, just the name without extension".
|
||||
label: stripExtension(file.name) || `文件 ${idx + 1}`,
|
||||
kind: "file",
|
||||
fileIndex: file.index,
|
||||
fileName: file.name,
|
||||
}));
|
||||
const list: ContentTab[] = [];
|
||||
if (isHtmlRun) {
|
||||
list.push({ value: HTML_TAB_VALUE, label: "HTML 预览", kind: "html" });
|
||||
}
|
||||
list.push(
|
||||
...markdownFiles.map((file, idx) => ({
|
||||
value: `file-${file.index}`,
|
||||
// Show the actual filename without the `.md` suffix — the user explicitly
|
||||
// asked for "no '文件' label, just the name without extension".
|
||||
label: stripExtension(file.name) || `文件 ${idx + 1}`,
|
||||
kind: "file" as const,
|
||||
fileIndex: file.index,
|
||||
fileName: file.name,
|
||||
})),
|
||||
);
|
||||
list.push({
|
||||
value: BODY_TAB_VALUE,
|
||||
label: "正文",
|
||||
@@ -610,7 +729,7 @@ function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) {
|
||||
fileName: run.result?.title || "scheduled-task-result",
|
||||
});
|
||||
return list;
|
||||
}, [markdownFiles, run.result?.title]);
|
||||
}, [isHtmlRun, markdownFiles, run.result?.title]);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<string>(tabs[0]?.value ?? BODY_TAB_VALUE);
|
||||
|
||||
@@ -751,13 +870,13 @@ function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) {
|
||||
下载原文件
|
||||
</a>
|
||||
)}
|
||||
{!isHtmlRun && (
|
||||
{activeTabMeta?.kind !== "html" && (
|
||||
<Button size="sm" variant="outline" onClick={handleDownloadWord}>
|
||||
<FileText className="size-4" />
|
||||
下载 Word
|
||||
</Button>
|
||||
)}
|
||||
{!isHtmlRun &&
|
||||
{activeTabMeta?.kind !== "html" &&
|
||||
(isEditing ? (
|
||||
<>
|
||||
<Button
|
||||
@@ -803,22 +922,7 @@ function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) {
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="min-h-0 flex-1 overflow-hidden p-0">
|
||||
{isHtmlRun ? (
|
||||
<div className="h-full p-4">
|
||||
{htmlContent ? (
|
||||
<iframe
|
||||
title="HTML 预览"
|
||||
sandbox="allow-same-origin"
|
||||
srcDoc={htmlContent}
|
||||
className="h-full min-h-[60vh] w-full rounded-md border bg-white"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-muted-foreground rounded-md border p-3 text-sm">
|
||||
HTML 页面尚未生成或正在生成中。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : tabs.length === 0 ? (
|
||||
{tabs.length === 0 ? (
|
||||
<div className="text-muted-foreground p-6 text-sm">无可用内容。</div>
|
||||
) : (
|
||||
<Tabs
|
||||
@@ -848,7 +952,18 @@ function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) {
|
||||
{run.error}
|
||||
</div>
|
||||
)}
|
||||
{fileIsLoading ? (
|
||||
{activeTabMeta?.kind === "html" ? (
|
||||
htmlContent ? (
|
||||
<HtmlPreviewFrame
|
||||
html={htmlContent}
|
||||
className="min-h-[60vh] w-full flex-1 rounded-md border bg-white"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-muted-foreground rounded-md border p-3 text-sm">
|
||||
HTML 页面尚未生成或正在生成中。
|
||||
</div>
|
||||
)
|
||||
) : fileIsLoading ? (
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
正在加载文件内容...
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
WorkspaceHeader,
|
||||
} from "@/components/workspace/workspace-container";
|
||||
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
||||
import { HtmlPreviewFrame } from "@/components/html-preview-frame";
|
||||
import {
|
||||
downloadScheduledTaskRun,
|
||||
downloadScheduledTaskRunFile,
|
||||
@@ -97,23 +98,7 @@ function RenderPreview({ file }: { file: ScheduledTaskResultFileContent }) {
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <pre className="whitespace-pre-wrap text-sm">{file.content}</pre>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sandboxed preview of a generated HTML document. The iframe has no
|
||||
* ``allow-scripts`` token, so embedded JS cannot run — defense-in-depth on top
|
||||
* of the server-side sanitization that already stripped script vectors.
|
||||
*/
|
||||
function HtmlPreviewFrame({ html }: { html: string }) {
|
||||
return (
|
||||
<iframe
|
||||
title="HTML 预览"
|
||||
sandbox="allow-same-origin"
|
||||
srcDoc={html}
|
||||
className="h-[70vh] w-full rounded-md border bg-white"
|
||||
/>
|
||||
);
|
||||
return <pre className="whitespace-pre-wrap break-words text-sm">{file.content}</pre>;
|
||||
}
|
||||
|
||||
type EditState = { draft: string; saving: boolean };
|
||||
@@ -162,9 +147,9 @@ function ExecutionMessage({ message }: { message: ScheduledTaskRunMessage }) {
|
||||
const isAi = message.event_type === "llm.ai.response";
|
||||
|
||||
return (
|
||||
<div className="rounded-md border">
|
||||
<div className="flex items-center gap-2 border-b px-3 py-1.5">
|
||||
<span className={cn("rounded px-1.5 py-0.5 text-xs font-medium", role.tone)}>
|
||||
<div className="min-w-0 rounded-md border">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2 border-b px-3 py-1.5">
|
||||
<span className={cn("max-w-full break-all rounded px-1.5 py-0.5 text-xs font-medium", role.tone)}>
|
||||
{role.label}
|
||||
{toolName ? ` · ${toolName}` : ""}
|
||||
</span>
|
||||
@@ -270,7 +255,16 @@ export default function ScheduledTaskRunDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const resultFiles = useMemo(() => result?.files ?? [], [result]);
|
||||
// The generated HTML document is already shown in the dedicated "HTML 预览"
|
||||
// tab, so it is excluded from the "结果文件" list — that list surfaces the
|
||||
// Markdown (and any other non-HTML artifact) only.
|
||||
const resultFiles = useMemo(
|
||||
() =>
|
||||
(result?.files ?? []).filter(
|
||||
(f) => !(f.kind === "html" || (f.name ?? "").toLowerCase().endsWith(".html")),
|
||||
),
|
||||
[result],
|
||||
);
|
||||
const fileCount = resultFiles.length;
|
||||
const resultMetadata = useMemo(() => {
|
||||
if (!result) return "";
|
||||
@@ -295,18 +289,28 @@ export default function ScheduledTaskRunDetailPage() {
|
||||
}
|
||||
setFilesLoading(true);
|
||||
try {
|
||||
const loaded = await Promise.all(
|
||||
// Load each file independently. A single failing file (e.g. an archive
|
||||
// that 404s) must NOT blank the whole panel — otherwise the tab shows
|
||||
// "结果文件(2)" while the body says "没有生成文件". Render every file
|
||||
// that loads and skip the ones that don't.
|
||||
const settled = await Promise.allSettled(
|
||||
resultFiles.map((f) => {
|
||||
const index = result.files.indexOf(f);
|
||||
return getScheduledTaskRunFileContent(run.id, index);
|
||||
}),
|
||||
);
|
||||
if (!cancelled) {
|
||||
const loaded = settled
|
||||
.filter(
|
||||
(r): r is PromiseFulfilledResult<ScheduledTaskResultFileContent> =>
|
||||
r.status === "fulfilled",
|
||||
)
|
||||
.map((r) => r.value);
|
||||
setFileContents(loaded);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
toast.error(error instanceof Error ? error.message : "加载文件内容失败");
|
||||
const failed = settled.length - loaded.length;
|
||||
if (failed > 0 && loaded.length === 0) {
|
||||
toast.error("结果文件已不可用(可能已被清理)");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
@@ -428,9 +432,9 @@ export default function ScheduledTaskRunDetailPage() {
|
||||
<WorkspaceHeader />
|
||||
<WorkspaceBody>
|
||||
<div className="flex size-full min-h-0 flex-col p-3">
|
||||
<Card className="flex min-h-0 flex-1 flex-col">
|
||||
<Card className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
{/* 工具栏 */}
|
||||
<div className="flex flex-wrap items-center gap-2 border-b px-3 py-2">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2 border-b px-3 py-2">
|
||||
<div className="mr-auto flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">
|
||||
{result?.title || (isLoading ? "正在加载..." : "执行结果详情")}
|
||||
@@ -470,15 +474,15 @@ export default function ScheduledTaskRunDetailPage() {
|
||||
{/* 执行信息(可折叠) */}
|
||||
{showMeta && run && (
|
||||
<div className="border-b px-3 py-2">
|
||||
<div className="text-muted-foreground grid gap-x-4 gap-y-1 text-xs md:grid-cols-3 xl:grid-cols-5">
|
||||
<div>ID:{run.id}</div>
|
||||
<div className="text-muted-foreground grid min-w-0 gap-x-4 gap-y-1 text-xs md:grid-cols-3 xl:grid-cols-5">
|
||||
<div className="min-w-0 break-all">ID:{run.id}</div>
|
||||
<div>计划:{formatDate(run.scheduled_for)}</div>
|
||||
<div>开始:{formatDate(run.started_at)}</div>
|
||||
<div>结束:{formatDate(run.finished_at)}</div>
|
||||
<div>耗时:{formatDuration(run)}</div>
|
||||
</div>
|
||||
{run.error && (
|
||||
<div className="text-destructive mt-2 rounded border border-destructive/30 bg-destructive/5 p-2 text-xs">
|
||||
<div className="text-destructive mt-2 break-words rounded border border-destructive/30 bg-destructive/5 p-2 text-xs">
|
||||
{run.error}
|
||||
</div>
|
||||
)}
|
||||
@@ -502,8 +506,8 @@ export default function ScheduledTaskRunDetailPage() {
|
||||
}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<div className="border-b px-3 pt-1">
|
||||
<TabsList className="h-8">
|
||||
<div className="min-w-0 overflow-x-auto border-b px-3 pt-1">
|
||||
<TabsList className="h-8 w-max">
|
||||
{result && isHtmlRun && (
|
||||
<TabsTrigger value="html" className="text-xs">
|
||||
<Code2 className="mr-1 size-3" />
|
||||
@@ -573,7 +577,7 @@ export default function ScheduledTaskRunDetailPage() {
|
||||
rehypePlugins={[]}
|
||||
/>
|
||||
) : (
|
||||
<pre className="whitespace-pre-wrap text-sm">
|
||||
<pre className="whitespace-pre-wrap break-words text-sm">
|
||||
{result.content || "无正文内容"}
|
||||
</pre>
|
||||
)}
|
||||
@@ -584,7 +588,9 @@ export default function ScheduledTaskRunDetailPage() {
|
||||
)}
|
||||
{!filesLoading && fileContents.length === 0 && (
|
||||
<div className="text-muted-foreground rounded-md border p-3 text-sm">
|
||||
本次执行没有生成文件。
|
||||
{fileCount > 0
|
||||
? `本次执行生成了 ${fileCount} 个文件,但当前无法读取(文件可能已被清理或移动)。可尝试「下载全部」。`
|
||||
: "本次执行没有生成文件。"}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-3">
|
||||
@@ -594,9 +600,9 @@ export default function ScheduledTaskRunDetailPage() {
|
||||
const editState = edits[file.index] ?? null;
|
||||
const isEditing = editState !== null;
|
||||
return (
|
||||
<div key={file.index} className="rounded-md border">
|
||||
<div key={file.index} className="min-w-0 rounded-md border">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b p-2">
|
||||
<div className="min-w-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">{file.name}</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{file.mime_type || file.kind || "附件"}
|
||||
@@ -679,7 +685,7 @@ export default function ScheduledTaskRunDetailPage() {
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="meta" className="m-0 p-3">
|
||||
<pre className="bg-muted overflow-auto rounded-md p-3 text-xs">
|
||||
<pre className="bg-muted overflow-auto whitespace-pre-wrap break-words rounded-md p-3 text-xs">
|
||||
{resultMetadata}
|
||||
</pre>
|
||||
</TabsContent>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertCircle,
|
||||
BellOff,
|
||||
@@ -113,13 +114,15 @@ const emptyForm = {
|
||||
timezone: "Asia/Shanghai",
|
||||
prompt: "",
|
||||
execution_agent_name: "",
|
||||
// Two independent output switches (may both be on): require a Markdown file
|
||||
// and/or render an HTML page. The form no longer splits into "普通任务 / HTML
|
||||
// 页面" tabs.
|
||||
require_markdown: false,
|
||||
// HTML page task fields. ``task_kind`` "agent" keeps the original behavior.
|
||||
// Data gathering is delegated to the agent's own search skills — no explicit
|
||||
// search query / data source. ``html_model_name`` empty means "use the first
|
||||
// available model" — never a hardcoded GLM-5.
|
||||
task_kind: "agent" as "agent" | "html_page",
|
||||
html_page_prompt: "",
|
||||
require_html: false,
|
||||
// HTML-only extra fields, shown when ``require_html`` is on. The main
|
||||
// ``prompt`` above doubles as the page requirement, so there is no separate
|
||||
// page-prompt field. ``html_model_name`` empty means "use the first available
|
||||
// model" — never a hardcoded GLM-5.
|
||||
html_negative_prompt: "",
|
||||
html_reference_favorite_id: "",
|
||||
html_model_name: "",
|
||||
@@ -133,6 +136,7 @@ const DESCRIPTION_TEXTAREA_ROWS = 4;
|
||||
const PROMPT_TEXTAREA_ROWS = 10;
|
||||
|
||||
export default function ScheduledTasksPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { theme } = useTheme();
|
||||
const darkToastOpts =
|
||||
theme === "dark"
|
||||
@@ -242,11 +246,15 @@ export default function ScheduledTasksPage() {
|
||||
description: task.description ?? "",
|
||||
schedule: task.schedule_text,
|
||||
timezone: task.timezone,
|
||||
prompt: task.prompt,
|
||||
// The main prompt doubles as the HTML page requirement; legacy HTML tasks
|
||||
// stored that under ``html_config.page_prompt``, so prefer it on edit.
|
||||
prompt: html.page_prompt || task.prompt,
|
||||
execution_agent_name: task.execution_agent_name ?? "",
|
||||
require_markdown: !!task.execution_context?.require_markdown,
|
||||
task_kind: task.execution_context?.task_kind === "html_page" ? "html_page" : "agent",
|
||||
html_page_prompt: html.page_prompt ?? "",
|
||||
// Backward compatible: legacy HTML tasks only carry ``task_kind``.
|
||||
require_html:
|
||||
!!task.execution_context?.require_html ||
|
||||
task.execution_context?.task_kind === "html_page",
|
||||
html_negative_prompt: html.negative_prompt ?? "",
|
||||
html_reference_favorite_id: html.reference_favorite_id ?? "",
|
||||
html_model_name: html.model_name ?? "",
|
||||
@@ -268,20 +276,26 @@ export default function ScheduledTasksPage() {
|
||||
setScheduleConfig(DEFAULT_SCHEDULE_CONFIG);
|
||||
};
|
||||
|
||||
const isHtmlTask = form.task_kind === "html_page";
|
||||
const isHtmlTask = form.require_html;
|
||||
// The model actually shown/used: the user's choice, else the first configured
|
||||
// model. Never a hardcoded GLM-5 — it follows config order and the selection.
|
||||
const effectiveHtmlModel = form.html_model_name || availableModels[0]?.name || "";
|
||||
|
||||
// Build the execution_context for the current form. HTML tasks persist their
|
||||
// config under ``html_config``; regular tasks send an explicit ``agent`` kind
|
||||
// so switching a task back from HTML clears the old config.
|
||||
// Build the execution_context for the current form. ``require_html`` and
|
||||
// ``require_markdown`` are independent toggles (both may be on). HTML tasks
|
||||
// persist their extra config under ``html_config`` and reuse the main prompt
|
||||
// as the page requirement; ``task_kind`` is kept in sync only for backward
|
||||
// compatibility with older readers.
|
||||
const buildExecutionContext = (): ScheduledTaskExecutionContext => {
|
||||
if (!isHtmlTask) return { task_kind: "agent", html_config: undefined };
|
||||
if (!isHtmlTask) {
|
||||
return { require_html: false, task_kind: "agent", html_config: undefined };
|
||||
}
|
||||
return {
|
||||
require_html: true,
|
||||
task_kind: "html_page",
|
||||
html_config: {
|
||||
page_prompt: form.html_page_prompt.trim() || null,
|
||||
// The main prompt doubles as the page requirement.
|
||||
page_prompt: form.prompt.trim() || null,
|
||||
negative_prompt: form.html_negative_prompt.trim() || null,
|
||||
reference_favorite_id: form.html_reference_favorite_id || null,
|
||||
// Send the user's selection; fall back to the first configured model,
|
||||
@@ -295,11 +309,10 @@ export default function ScheduledTasksPage() {
|
||||
try {
|
||||
const cronSchedule = buildCron(scheduleConfig);
|
||||
const executionContext = buildExecutionContext();
|
||||
// HTML tasks may leave the agent prompt empty — fall back to the page
|
||||
// prompt so the (non-null) prompt column always has something useful.
|
||||
const effectivePrompt = isHtmlTask
|
||||
? form.prompt.trim() || form.html_page_prompt.trim() || "生成 HTML 页面"
|
||||
: form.prompt;
|
||||
// The (non-null) prompt column always needs something useful; HTML tasks
|
||||
// may leave it sparse, so fall back to a generic instruction.
|
||||
const effectivePrompt =
|
||||
form.prompt.trim() || (isHtmlTask ? "生成 HTML 页面" : form.prompt);
|
||||
if (creating) {
|
||||
const task = await actions.create.mutateAsync({
|
||||
name: form.name || "定时任务",
|
||||
@@ -307,8 +320,8 @@ export default function ScheduledTasksPage() {
|
||||
schedule: cronSchedule,
|
||||
timezone: form.timezone || "Asia/Shanghai",
|
||||
prompt: effectivePrompt,
|
||||
execution_agent_name: isHtmlTask ? null : form.execution_agent_name || null,
|
||||
require_markdown: isHtmlTask ? false : form.require_markdown,
|
||||
execution_agent_name: form.execution_agent_name || null,
|
||||
require_markdown: form.require_markdown,
|
||||
execution_context: executionContext,
|
||||
});
|
||||
setSelectedTaskId(task.task_id);
|
||||
@@ -322,8 +335,8 @@ export default function ScheduledTasksPage() {
|
||||
schedule: cronSchedule,
|
||||
timezone: form.timezone,
|
||||
prompt: effectivePrompt,
|
||||
execution_agent_name: isHtmlTask ? null : form.execution_agent_name || null,
|
||||
require_markdown: isHtmlTask ? false : form.require_markdown,
|
||||
execution_agent_name: form.execution_agent_name || null,
|
||||
require_markdown: form.require_markdown,
|
||||
execution_context: executionContext,
|
||||
},
|
||||
});
|
||||
@@ -361,15 +374,35 @@ export default function ScheduledTasksPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleTriggerTask = async (task: ScheduledTask) => {
|
||||
setSelectedTaskId(task.task_id);
|
||||
setRunsTimeFilter("all");
|
||||
try {
|
||||
await actions.trigger.mutateAsync(task.task_id);
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["scheduled-tasks", task.task_id, "runs"],
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["scheduled-tasks", "list"],
|
||||
});
|
||||
toast.success("已触发执行", darkToastOpts.success);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "触发失败",
|
||||
darkToastOpts.error,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<WorkspaceContainer>
|
||||
<WorkspaceHeader />
|
||||
<WorkspaceBody>
|
||||
<div className="grid size-full min-h-0 grid-cols-1 gap-4 p-4 xl:grid-cols-[420px_minmax(0,1fr)]">
|
||||
<Card className="flex min-h-0 flex-col">
|
||||
<div className="grid size-full min-h-0 grid-cols-1 grid-rows-[minmax(0,1fr)_minmax(0,1fr)] gap-4 p-4 xl:grid-cols-[420px_minmax(0,1fr)] xl:grid-rows-1">
|
||||
<Card className="flex min-h-0 min-w-0 flex-col">
|
||||
<CardHeader className="gap-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="min-w-0">
|
||||
<CardTitle>定时任务</CardTitle>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
选择任务后,在右侧查看全部执行记录
|
||||
@@ -480,7 +513,8 @@ export default function ScheduledTasksPage() {
|
||||
<Globe className="size-3" />已发布
|
||||
</Badge>
|
||||
)}
|
||||
{task.execution_context?.task_kind === "html_page" && (
|
||||
{(task.execution_context?.require_html ||
|
||||
task.execution_context?.task_kind === "html_page") && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="shrink-0 border-sky-500/30 bg-sky-500/10 text-xs text-sky-600 dark:text-sky-400"
|
||||
@@ -563,8 +597,7 @@ export default function ScheduledTasksPage() {
|
||||
title="立即执行"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
actions.trigger.mutate(task.task_id);
|
||||
toast.success("已触发执行", darkToastOpts.success);
|
||||
void handleTriggerTask(task);
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="size-4" />
|
||||
@@ -715,9 +748,9 @@ export default function ScheduledTasksPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="flex min-h-0 flex-col">
|
||||
<Card className="flex min-h-0 min-w-0 flex-col">
|
||||
<CardHeader className="gap-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center justify-between gap-2">
|
||||
<CardTitle>执行记录</CardTitle>
|
||||
<Select value={runsTimeFilter} onValueChange={(v) => setRunsTimeFilter(v as typeof runsTimeFilter)}>
|
||||
<SelectTrigger className="h-7 w-28 text-xs">
|
||||
@@ -731,7 +764,7 @@ export default function ScheduledTasksPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<p className="text-muted-foreground min-w-0 truncate text-sm">
|
||||
{selectedTask ? desensitize(selectedTask.name) : "请选择左侧任务"}
|
||||
</p>
|
||||
{selectedTask && (selectedTask.creator_email || selectedTask.creator_user_id) && (
|
||||
@@ -770,11 +803,11 @@ export default function ScheduledTasksPage() {
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium">
|
||||
{run.result?.title ? desensitize(run.result.title) : formatDate(run.scheduled_for)}
|
||||
</div>
|
||||
<div className="text-muted-foreground mt-1 text-xs">
|
||||
<div className="text-muted-foreground mt-1 break-words text-xs">
|
||||
计划:{formatDate(run.scheduled_for)} · 开始:
|
||||
{formatDate(run.started_at)}
|
||||
</div>
|
||||
@@ -932,20 +965,6 @@ export default function ScheduledTasksPage() {
|
||||
}
|
||||
placeholder="任务描述(可选,将显示在公开页标题下方)"
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-muted-foreground text-xs">任务类型</p>
|
||||
<Tabs
|
||||
value={form.task_kind}
|
||||
onValueChange={(v) =>
|
||||
setForm((value) => ({ ...value, task_kind: v as "agent" | "html_page" }))
|
||||
}
|
||||
>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="agent" className="flex-1">普通任务</TabsTrigger>
|
||||
<TabsTrigger value="html_page" className="flex-1">HTML 页面</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-muted-foreground text-xs">执行频率</p>
|
||||
<ScheduleBuilder value={scheduleConfig} onChange={setScheduleConfig} />
|
||||
@@ -958,77 +977,83 @@ export default function ScheduledTasksPage() {
|
||||
placeholder="时区,例如:Asia/Shanghai"
|
||||
/>
|
||||
|
||||
{!isHtmlTask && (
|
||||
<>
|
||||
<Select
|
||||
value={form.execution_agent_name || "__default__"}
|
||||
onValueChange={(v) =>
|
||||
setForm((value) => ({
|
||||
...value,
|
||||
execution_agent_name: v === "__default__" ? "" : v,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full min-w-0 max-w-full overflow-hidden [&>span]:min-w-0 [&>span]:flex-1 [&>span]:truncate">
|
||||
<SelectValue placeholder="选择执行智能体" />
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
position="popper"
|
||||
className="w-[var(--radix-select-trigger-width)] max-w-[var(--radix-select-trigger-width)]"
|
||||
>
|
||||
<SelectItem value="__default__">默认助手(不指定)</SelectItem>
|
||||
{agents.map((agent) => (
|
||||
<SelectItem key={agent.id} value={agent.id}>
|
||||
<span className="block truncate">
|
||||
{desensitize(agent.name)}
|
||||
{agent.description ? ` — ${desensitize(agent.description)}` : ""}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Textarea
|
||||
className="min-h-40"
|
||||
value={form.prompt}
|
||||
onChange={(event) =>
|
||||
setForm((value) => ({ ...value, prompt: event.target.value }))
|
||||
}
|
||||
placeholder="任务 Prompt"
|
||||
/>
|
||||
<div className="flex items-center justify-between rounded-md border p-3">
|
||||
<div className="min-w-0 pr-3">
|
||||
<p className="text-sm font-medium">必须生成 Markdown 文件</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
开启后会提示大模型生成 Markdown(.md) 文件;系统会校验本次是否生成了 .md 文件且文件正文超过 100 字,未达到则记为失败。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={form.require_markdown}
|
||||
onCheckedChange={(v) =>
|
||||
setForm((value) => ({ ...value, require_markdown: v }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<Select
|
||||
value={form.execution_agent_name || "__default__"}
|
||||
onValueChange={(v) =>
|
||||
setForm((value) => ({
|
||||
...value,
|
||||
execution_agent_name: v === "__default__" ? "" : v,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full min-w-0 max-w-full overflow-hidden [&>span]:min-w-0 [&>span]:flex-1 [&>span]:truncate">
|
||||
<SelectValue placeholder="选择执行智能体" />
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
position="popper"
|
||||
className="max-h-[40vh] w-[max(var(--radix-select-trigger-width),28rem)] max-w-[min(90vw,40rem)]"
|
||||
>
|
||||
<SelectItem value="__default__">默认助手(不指定)</SelectItem>
|
||||
{agents.map((agent) => (
|
||||
<SelectItem key={agent.id} value={agent.id}>
|
||||
<span className="block whitespace-normal break-words">
|
||||
{desensitize(agent.name)}
|
||||
{agent.description ? ` — ${desensitize(agent.description)}` : ""}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Textarea
|
||||
className="min-h-40"
|
||||
value={form.prompt}
|
||||
onChange={(event) =>
|
||||
setForm((value) => ({ ...value, prompt: event.target.value }))
|
||||
}
|
||||
placeholder={
|
||||
isHtmlTask
|
||||
? "任务 / 页面生成要求,例如:检索新能源车最新舆情,生成适合领导快速浏览的态势页"
|
||||
: "任务 Prompt"
|
||||
}
|
||||
/>
|
||||
<div className="flex items-center justify-between rounded-md border p-3">
|
||||
<div className="min-w-0 pr-3">
|
||||
<p className="text-sm font-medium">必须生成 Markdown 文件</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
开启后会提示大模型生成 Markdown(.md) 文件;系统会校验本次是否生成了 .md 文件且文件正文超过 100 字,未达到则记为失败。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={form.require_markdown}
|
||||
onCheckedChange={(v) =>
|
||||
setForm((value) => ({ ...value, require_markdown: v }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-md border p-3">
|
||||
<div className="min-w-0 pr-3">
|
||||
<p className="text-sm font-medium">必须生成 HTML 页面</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
开启后会先用智能体检索整理真实数据,再渲染为完整 HTML 页面(可在执行记录中预览)。可与“必须生成 Markdown”同时开启。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={form.require_html}
|
||||
onCheckedChange={(v) =>
|
||||
setForm((value) => ({ ...value, require_html: v }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isHtmlTask && (
|
||||
<>
|
||||
<Textarea
|
||||
className="min-h-24"
|
||||
value={form.html_page_prompt}
|
||||
onChange={(event) =>
|
||||
setForm((value) => ({ ...value, html_page_prompt: event.target.value }))
|
||||
}
|
||||
placeholder="页面生成要求,例如:检索新能源车最新舆情,生成适合领导快速浏览的态势页"
|
||||
/>
|
||||
<Textarea
|
||||
className="min-h-16"
|
||||
value={form.html_negative_prompt}
|
||||
onChange={(event) =>
|
||||
setForm((value) => ({ ...value, html_negative_prompt: event.target.value }))
|
||||
}
|
||||
placeholder="限制条件(可选),例如:不要使用深色背景,不要出现外部链接脚本"
|
||||
placeholder="HTML 限制条件(可选),例如:不要使用深色背景,不要出现外部链接脚本"
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-muted-foreground text-xs">参考收藏页面(可选)</p>
|
||||
@@ -1106,14 +1131,6 @@ export default function ScheduledTasksPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Textarea
|
||||
className="min-h-16"
|
||||
value={form.prompt}
|
||||
onChange={(event) =>
|
||||
setForm((value) => ({ ...value, prompt: event.target.value }))
|
||||
}
|
||||
placeholder="补充说明(可选):进一步约束智能体如何检索数据、页面包含哪些内容"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
执行流程:智能体运用自身检索技能整理真实数据 → 选定模型渲染为完整 HTML → 自动安全清洗与结构校验 → 可在执行记录中预览。
|
||||
</p>
|
||||
|
||||
@@ -24,6 +24,8 @@ import LlmMetricsPage from "./LlmMetricsPage";
|
||||
import UsersPage from "./UsersPage";
|
||||
import AdminScheduledTasksPage from "./AdminScheduledTasksPage";
|
||||
import AdminUserActivityPage from "./AdminUserActivityPage";
|
||||
import AdminUserLeaderboardPage from "./AdminUserLeaderboardPage";
|
||||
import AdminUserLeaderboardSettingsPage from "./AdminUserLeaderboardSettingsPage";
|
||||
import TagsPage from "./TagsPage";
|
||||
import LightAppNewNotePage from "./LightAppNewNotePage";
|
||||
import LightAppNoteListPage from "./LightAppNoteListPage";
|
||||
@@ -254,6 +256,26 @@ export default function StrategyRoutes() {
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/user-leaderboard"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<AdminUserLeaderboardPage />
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/user-leaderboard/settings"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<AdminUserLeaderboardSettingsPage />
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/user-activity"
|
||||
element={
|
||||
|
||||
@@ -100,3 +100,37 @@ test("maps iframe model selection into stream context model_name", () => {
|
||||
model_name: "OpenAI3",
|
||||
});
|
||||
});
|
||||
|
||||
test("maps externally uploaded files into stream additional_kwargs.files", () => {
|
||||
const result = parseBrainyChatSubmitMessage({
|
||||
source: "brainy-extension",
|
||||
type: "brainy:chat-submit",
|
||||
requestId: "iframe-789",
|
||||
payload: {
|
||||
text: "网摘",
|
||||
prompt: "网摘",
|
||||
uploadedFiles: [
|
||||
{
|
||||
filename: "LLM Wiki 接口说明.md",
|
||||
size: "7002",
|
||||
path: "/mnt/user-data/uploads/LLM Wiki 接口说明.md",
|
||||
status: "uploaded",
|
||||
artifact_url:
|
||||
"/api/threads/445ca7a7-9737-44f0-8eea-6887776f4f9c/artifacts/mnt/user-data/uploads/LLM%20Wiki%20%E6%8E%A5%E5%8F%A3%E8%AF%B4%E6%98%8E.md",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.submitText, "网摘");
|
||||
assert.deepEqual(result.additionalKwargs?.files, [
|
||||
{
|
||||
filename: "LLM Wiki 接口说明.md",
|
||||
size: "7002",
|
||||
path: "/mnt/user-data/uploads/LLM Wiki 接口说明.md",
|
||||
status: "uploaded",
|
||||
artifact_url:
|
||||
"/api/threads/445ca7a7-9737-44f0-8eea-6887776f4f9c/artifacts/mnt/user-data/uploads/LLM%20Wiki%20%E6%8E%A5%E5%8F%A3%E8%AF%B4%E6%98%8E.md",
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -26,6 +26,14 @@ type BrainyModelSelection = {
|
||||
value?: string;
|
||||
};
|
||||
|
||||
type BrainyUploadedFile = {
|
||||
filename?: string;
|
||||
size?: string | number;
|
||||
path?: string;
|
||||
status?: string;
|
||||
artifact_url?: string;
|
||||
};
|
||||
|
||||
export type BrainyChatSubmitMessage = {
|
||||
source?: string;
|
||||
type?: string;
|
||||
@@ -35,6 +43,7 @@ export type BrainyChatSubmitMessage = {
|
||||
pageContext?: BrainyPageContext;
|
||||
screenshot?: BrainyScreenshot;
|
||||
model?: BrainyModelSelection;
|
||||
uploadedFiles?: BrainyUploadedFile[];
|
||||
};
|
||||
requestId?: string;
|
||||
};
|
||||
@@ -253,6 +262,36 @@ function buildScreenshotFileParts(
|
||||
return [];
|
||||
}
|
||||
|
||||
function normalizeUploadedFiles(
|
||||
uploadedFiles: BrainyUploadedFile[] | undefined,
|
||||
): Record<string, unknown>[] {
|
||||
if (!Array.isArray(uploadedFiles)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return uploadedFiles
|
||||
.map((file) => {
|
||||
const filename = trimToUndefined(file.filename);
|
||||
if (!filename) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const size = String(file.size ?? "0");
|
||||
const path = trimToUndefined(file.path);
|
||||
const status = trimToUndefined(file.status);
|
||||
const artifactUrl = trimToUndefined(file.artifact_url);
|
||||
|
||||
return {
|
||||
filename,
|
||||
size,
|
||||
...(path ? { path } : {}),
|
||||
...(status ? { status } : {}),
|
||||
...(artifactUrl ? { artifact_url: artifactUrl } : {}),
|
||||
};
|
||||
})
|
||||
.filter((file): file is Record<string, unknown> => file !== null);
|
||||
}
|
||||
|
||||
export function parseBrainyChatSubmitMessage(
|
||||
data: BrainyChatSubmitMessage,
|
||||
): ParsedBrainyChatSubmitMessage {
|
||||
@@ -268,6 +307,7 @@ export function parseBrainyChatSubmitMessage(
|
||||
});
|
||||
const requestId = trimToUndefined(data.requestId);
|
||||
const modelName = trimToUndefined(data.payload?.model?.value);
|
||||
const uploadedFiles = normalizeUploadedFiles(data.payload?.uploadedFiles);
|
||||
|
||||
const iframeContext: Record<string, unknown> = {};
|
||||
if (requestId) {
|
||||
@@ -281,6 +321,13 @@ export function parseBrainyChatSubmitMessage(
|
||||
submitText,
|
||||
files,
|
||||
...(Object.keys(iframeContext).length > 0 ? { iframeContext } : {}),
|
||||
...(promptPrefix ? { additionalKwargs: { prompt_prefix: promptPrefix } } : {}),
|
||||
...(promptPrefix || uploadedFiles.length > 0
|
||||
? {
|
||||
additionalKwargs: {
|
||||
...(promptPrefix ? { prompt_prefix: promptPrefix } : {}),
|
||||
...(uploadedFiles.length > 0 ? { files: uploadedFiles } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user