Merge remote-tracking branch 'origin/master' into xuhd

# Conflicts:
#	offline-backend-20260512/backend/config.yaml
This commit is contained in:
xuhd committed 2026-06-09 15:43:40 +08:00
commit bd0eceda61
267 files changed
+41209 -14541

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>
+136 -71
View File
@@ -1,46 +1,21 @@
"use client";
import { Loader2, MessageSquareIcon, SearchIcon, UserIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { Loader2, MessageSquareIcon, SearchIcon, UserIcon, XIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Pagination } from "@/components/ui/pagination";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useUserMemory, useUserThreadMessages, useUserThreads } from "@/core/admin/hooks";
import type { AdminMemoryBucket, AdminMessage } from "@/core/admin/api";
import { useUserMemory, useUserQa, useUserThreads } from "@/core/admin/hooks";
import type { AdminMemoryBucket, AdminUserQaItem } from "@/core/admin/api";
import { useAllUsers } from "@/core/auth/hooks";
import { cn } from "@/lib/utils";
/** Flatten a langchain message dump (or raw string / block list) into text. */
function extractText(content: unknown): string {
if (content == null) return "";
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return content
.map((block) => {
if (typeof block === "string") return block;
if (block && typeof block === "object") {
const text = (block as Record<string, unknown>).text;
if (typeof text === "string") return text;
}
return "";
})
.join("");
}
if (typeof content === "object") {
const inner = (content as Record<string, unknown>).content;
if (inner !== undefined) return extractText(inner);
}
return "";
}
function roleOf(message: AdminMessage): "user" | "assistant" | null {
const type = (message.type ?? "").toLowerCase();
if (type === "human") return "user";
if (type === "ai" || type === "aimessagechunk") return "assistant";
return null; // tool results and system messages are hidden from the Q&A view
}
const QA_PAGE_SIZE = 20;
function fmtTime(iso: string): string {
if (!iso) return "";
@@ -48,24 +23,39 @@ function fmtTime(iso: string): string {
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
}
/** A single Q&A bubble. */
function MessageRow({ message }: { message: AdminMessage }) {
const role = roleOf(message);
if (!role) return null;
const text = extractText(message.content).trim();
if (!text) return null; // skip empty AI turns (e.g. tool-call-only responses)
function fmtNumber(value: number): string {
return new Intl.NumberFormat("zh-CN").format(value || 0);
}
function QaItemCard({ item }: { item: AdminUserQaItem }) {
const isError = item.status === "error" || item.status === "timeout";
return (
<div className={cn("flex flex-col gap-1", role === "user" ? "items-start" : "items-end")}>
<span className="text-muted-foreground text-xs">
{role === "user" ? "用户提问" : "AI 回答"}
</span>
<div
className={cn(
"max-w-[85%] whitespace-pre-wrap rounded-lg border px-3 py-2 text-sm",
role === "user" ? "bg-muted" : "bg-primary/5 border-primary/20",
)}
>
{text}
<div className="rounded-md border bg-background p-4">
<div className="mb-3 flex flex-wrap items-center gap-2">
<span className="text-muted-foreground text-xs">{fmtTime(item.created_at)}</span>
<Badge variant={isError ? "destructive" : "secondary"}>{item.status || "unknown"}</Badge>
{item.model_name && <Badge variant="outline">{item.model_name}</Badge>}
{item.assistant_id && <Badge variant="outline">{item.assistant_id}</Badge>}
<span className="text-muted-foreground text-xs">{fmtNumber(item.total_tokens)} tokens</span>
</div>
{(item.thread_title || item.thread_id) && (
<div className="text-muted-foreground mb-3 truncate text-xs">
{item.thread_title || "未命名会话"} · {item.thread_id}
</div>
)}
<div className="grid gap-3">
<div>
<div className="text-muted-foreground mb-1 text-xs font-medium">用户提问</div>
<div className="bg-muted whitespace-pre-wrap rounded-md px-3 py-2 text-sm">
{item.question || "空问题"}
</div>
</div>
<div>
<div className="text-muted-foreground mb-1 text-xs font-medium">AI 回答</div>
<div className="border-primary/20 bg-primary/5 max-h-64 overflow-auto whitespace-pre-wrap rounded-md border px-3 py-2 text-sm">
{item.answer || "暂无回答"}
</div>
</div>
</div>
</div>
);
@@ -109,19 +99,38 @@ 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 [qaQuery, setQaQuery] = useState("");
const [qaPage, setQaPage] = useState(1);
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(
userId,
threadId,
);
const { data: qaData, isLoading: qaLoading, isFetching: qaFetching } = useUserQa(userId, {
page: qaPage,
pageSize: QA_PAGE_SIZE,
q: qaQuery,
});
const { data: memory, isLoading: memoryLoading } = useUserMemory(userId);
useEffect(() => {
if (targetUserId) {
setUserId(targetUserId);
setThreadId(null);
setQaQuery("");
setQaPage(1);
setTab("qa");
}
}, [targetUserId]);
useEffect(() => {
setQaPage(1);
}, [userId, qaQuery]);
const filteredUsers = useMemo(() => {
const needle = userQuery.trim().toLowerCase();
const list = users ?? [];
@@ -133,9 +142,17 @@ export default function AdminUserActivityPage() {
const selectUser = (id: string) => {
setUserId(id);
setThreadId(null);
setQaQuery("");
setQaPage(1);
setTab("qa");
};
const clearQaFilter = () => {
setThreadId(null);
setQaQuery("");
setQaPage(1);
};
return (
<div className="flex size-full flex-col">
<div className="border-b px-6 py-4">
@@ -192,8 +209,19 @@ export default function AdminUserActivityPage() {
{/* Pane 2: threads */}
<div className="flex w-72 shrink-0 flex-col border-r">
<div className="text-muted-foreground border-b px-3 py-2 text-xs font-medium">
会话列表{threads ? `(${threads.length})` : ""}
<div className="flex items-center justify-between gap-2 border-b px-3 py-2">
<span className="text-muted-foreground text-xs font-medium">
会话列表{threads ? `(${threads.length})` : ""}
</span>
<Button
type="button"
variant="ghost"
size="sm"
disabled={!userId || (!threadId && !qaQuery)}
onClick={clearQaFilter}
>
全部
</Button>
</div>
<ScrollArea className="min-h-0 flex-1">
{!userId && (
@@ -218,6 +246,8 @@ export default function AdminUserActivityPage() {
type="button"
onClick={() => {
setThreadId(thread.thread_id);
setQaQuery(thread.thread_id);
setQaPage(1);
setTab("qa");
}}
className={cn(
@@ -259,28 +289,63 @@ export default function AdminUserActivityPage() {
<ScrollArea className="min-h-0 flex-1">
{tab === "qa" && (
<div className="p-4">
{!threadId && (
<div className="text-muted-foreground flex flex-col items-center gap-2 py-16 text-sm">
<MessageSquareIcon className="size-6" />
请从中间列选择一个会话
<div className="flex flex-col gap-4 p-4">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<div className="relative min-w-0 flex-1">
<SearchIcon className="text-muted-foreground absolute left-3 top-1/2 size-4 -translate-y-1/2" />
<Input
value={qaQuery}
onChange={(event) => {
setQaQuery(event.target.value);
setThreadId(null);
setQaPage(1);
}}
placeholder="搜索问题、回答、运行 ID 或会话 ID"
className="pl-9"
/>
</div>
{qaQuery && (
<Button type="button" variant="outline" size="sm" onClick={clearQaFilter}>
<XIcon className="size-4" />
清空
</Button>
)}
</div>
)}
{threadId && messagesLoading && (
<div className="text-muted-foreground flex flex-wrap items-center gap-2 text-xs">
<span>共 {fmtNumber(qaData?.total ?? 0)} 条问答</span>
{threadId && <Badge variant="outline">已按会话过滤</Badge>}
{qaFetching && !qaLoading && (
<span className="inline-flex items-center gap-1">
<Loader2 className="size-3 animate-spin" /> 更新中
</span>
)}
</div>
</div>
{qaLoading && (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-16 text-sm">
<Loader2 className="size-4 animate-spin" /> 加载中
</div>
)}
{threadId && !messagesLoading && (
{!qaLoading && (qaData?.items.length ?? 0) === 0 && (
<div className="text-muted-foreground flex flex-col items-center gap-2 py-16 text-sm">
<MessageSquareIcon className="size-6" />
暂无匹配的问答记录
</div>
)}
{!qaLoading && (qaData?.items.length ?? 0) > 0 && (
<div className="flex flex-col gap-3">
{(messages ?? []).map((m, i) => (
<MessageRow key={m.id ?? i} message={m} />
{(qaData?.items ?? []).map((item) => (
<QaItemCard key={item.run_id} item={item} />
))}
{(messages ?? []).every((m) => !roleOf(m)) && (
<div className="text-muted-foreground py-12 text-center text-sm">
该会话暂无问答记录
</div>
)}
<Pagination
page={qaPage}
pageSize={QA_PAGE_SIZE}
total={qaData?.total ?? 0}
onPageChange={setQaPage}
className="pt-2"
/>
</div>
)}
</div>
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>
);
}
+9 -3
View File
@@ -131,8 +131,8 @@ export default function AgentChatPage() {
const pendingMessageHandled = useRef(false);
useEffect(() => {
if (pendingMessageHandled.current) return;
if (!agent_id || !isNewThread || hasStartedConversation) return;
let pending: { agentId?: string; text?: string } | null = null;
if (!agent_id) return;
let pending: { agentId?: string; threadId?: string; text?: string } | null = null;
try {
const raw = sessionStorage.getItem("pendingAgentMessage");
if (raw) pending = JSON.parse(raw);
@@ -140,10 +140,15 @@ export default function AgentChatPage() {
pending = null;
}
if (!pending || pending.agentId !== agent_id || !pending.text) return;
if (pending.threadId) {
if (pending.threadId !== threadId) return;
} else if (!isNewThread) {
return;
}
pendingMessageHandled.current = true;
sessionStorage.removeItem("pendingAgentMessage");
handleSubmit({ text: pending.text, files: [] });
}, [agent_id, isNewThread, hasStartedConversation, handleSubmit]);
}, [agent_id, threadId, isNewThread, handleSubmit]);
const handleClarificationSubmit = useCallback(
(answer: string) => {
@@ -250,6 +255,7 @@ export default function AgentChatPage() {
className="w-full"
isNewThread={isLandingState}
threadId={threadId}
assistantId={agent_id}
autoFocus={isLandingState}
status={
thread.error
+61 -12
View File
@@ -1,10 +1,13 @@
"use client";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { getAPIClient } from "@/core/api";
import { usePromptPrefixSettings } from "@/core/system-settings/hooks";
import { useUserPreferencesQuery } from "@/core/user-preferences";
import { ArtifactTrigger, useArtifacts } from "@/components/workspace/artifacts";
import {
ChatBox,
@@ -50,8 +53,15 @@ 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 [agentHandoffPending, setAgentHandoffPending] = useState(false);
const agentHandoffPendingRef = useRef(false);
const { agents: selectorAgents, isLoading: selectorLoading } =
useAgentSelector();
const { selectedArtifact } = useArtifacts();
@@ -142,15 +152,37 @@ export default function ChatPage() {
message.text &&
(!message.files || message.files.length === 0)
) {
try {
sessionStorage.setItem(
"pendingAgentMessage",
JSON.stringify({ agentId: selectedAgentId, text: message.text }),
);
} catch {
// sessionStorage may be unavailable in private mode; fall through.
}
router.push(`/page/workspace/agents/${selectedAgentId}/chats/new`);
if (agentHandoffPendingRef.current) return;
const text = message.text.trim();
agentHandoffPendingRef.current = true;
setAgentHandoffPending(true);
setHasStartedConversation(true);
void (async () => {
try {
const created = await getAPIClient(isMock).threads.create({
metadata: { agent_id: selectedAgentId },
});
sessionStorage.setItem(
"pendingAgentMessage",
JSON.stringify({
agentId: selectedAgentId,
threadId: created.thread_id,
text,
}),
);
saveLastAgentId(selectedAgentId);
router.push(
`/page/workspace/agents/${selectedAgentId}/chats/${created.thread_id}`,
);
} catch (err) {
agentHandoffPendingRef.current = false;
setAgentHandoffPending(false);
setHasStartedConversation(false);
toast.error(
err instanceof Error ? err.message : "创建智能体对话失败",
);
}
})();
return;
}
@@ -159,6 +191,8 @@ export default function ChatPage() {
isNewThread &&
!hasStartedConversation &&
promptPrefixOn &&
!preferencesLoading &&
!hideAdminPrompt &&
promptPrefixSettings?.enabled === true;
setHasStartedConversation(true);
window.dispatchEvent(
@@ -189,7 +223,11 @@ export default function ChatPage() {
hasStartedConversation,
promptPrefixOn,
promptPrefixSettings,
hideAdminPrompt,
preferencesLoading,
selectedAgentId,
agentHandoffPending,
isMock,
router,
settings.context.writing_mode,
writingArtifactPath,
@@ -217,6 +255,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 +265,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 +
@@ -325,7 +373,8 @@ export default function ChatPage() {
context={settings.context}
disabled={
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ||
isUploading
isUploading ||
agentHandoffPending
}
onContextChange={(context) =>
setSettings("context", context)
@@ -0,0 +1,257 @@
"use client";
import { DatabaseZap, Loader2Icon, RefreshCwIcon } from "lucide-react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
interface CheckpointMigrationSources {
source_paths: string[];
}
interface CheckpointMigrationTask {
task_id: string;
status: "queued" | "running" | "completed" | "failed";
start_at: string;
end_at: string;
source_paths: string[];
total_threads: number;
copied_threads: number;
skipped_threads: number;
failed_threads: number;
skipped_ranges: number;
message: string;
error?: string | null;
created_at: string;
updated_at: string;
}
async function loadSources(): Promise<CheckpointMigrationSources> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/admin/checkpoint-migration/sources`,
);
if (!response.ok) throw new Error("加载 SQLite 来源失败");
return (await response.json()) as CheckpointMigrationSources;
}
async function startMigration(body: {
start_at: string;
end_at: string;
}): Promise<CheckpointMigrationTask> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/admin/checkpoint-migration/tasks`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
if (!response.ok) {
const detail = await response.json().catch(() => ({ detail: "启动迁移失败" }));
throw new Error(detail.detail ?? "启动迁移失败");
}
return (await response.json()) as CheckpointMigrationTask;
}
async function loadTask(taskId: string): Promise<CheckpointMigrationTask> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/admin/checkpoint-migration/tasks/${taskId}`,
);
if (!response.ok) throw new Error("加载迁移任务失败");
return (await response.json()) as CheckpointMigrationTask;
}
function toLocalInputValue(date: Date): string {
const pad = (n: number) => String(n).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
function statusVariant(status?: string) {
if (status === "failed") return "destructive" as const;
if (status === "completed") return "default" as const;
if (status === "queued" || status === "running") return "secondary" as const;
return "outline" as const;
}
export default function CheckpointMigrationPage() {
const now = useMemo(() => new Date(), []);
const weekAgo = useMemo(
() => new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000),
[now],
);
const [startAt, setStartAt] = useState(toLocalInputValue(weekAgo));
const [endAt, setEndAt] = useState(toLocalInputValue(now));
const [taskId, setTaskId] = useState<string | null>(null);
const { data: sources, isLoading: sourcesLoading, refetch } = useQuery({
queryKey: ["checkpointMigration", "sources"],
queryFn: loadSources,
staleTime: 60_000,
refetchOnWindowFocus: false,
});
const startMutation = useMutation({ mutationFn: startMigration });
const { data: task } = useQuery({
queryKey: ["checkpointMigration", "task", taskId],
queryFn: () => loadTask(taskId as string),
enabled: !!taskId,
refetchInterval: (query) => {
const status = query.state.data?.status;
return status === "queued" || status === "running" ? 2000 : false;
},
refetchOnWindowFocus: false,
});
const running =
startMutation.isPending ||
task?.status === "queued" ||
task?.status === "running";
async function handleStart() {
const start = new Date(startAt);
const end = new Date(endAt);
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
toast.error("请选择有效的开始和结束时间");
return;
}
if (end <= start) {
toast.error("结束时间必须晚于开始时间");
return;
}
try {
const created = await startMutation.mutateAsync({
start_at: start.toISOString(),
end_at: end.toISOString(),
});
setTaskId(created.task_id);
toast.success("迁移任务已启动");
} catch (err) {
toast.error(err instanceof Error ? err.message : "启动迁移失败");
}
}
return (
<div className="mx-auto flex w-full max-w-3xl flex-col gap-6 px-6 py-10">
<div>
<h1 className="text-xl font-semibold">Checkpoint 迁移</h1>
<p className="text-muted-foreground mt-1 text-sm">
按时间段把 SQLite 旧聊天 checkpoint 同步到 Postgres。同一来源、同一时间段完成后再次执行会跳过。
</p>
</div>
<section className="rounded-lg border bg-white p-5 dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2 text-sm font-medium">
<DatabaseZap className="size-4" />
SQLite 来源
</div>
<Button
variant="ghost"
size="sm"
onClick={() => refetch()}
disabled={sourcesLoading}
title="刷新"
>
<RefreshCwIcon className="size-4" />
</Button>
</div>
{sourcesLoading ? (
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Loader2Icon className="size-4 animate-spin" />
加载中
</div>
) : (sources?.source_paths.length ?? 0) > 0 ? (
<div className="space-y-2">
{sources?.source_paths.map((path) => (
<div
key={path}
className="rounded border bg-muted/40 px-3 py-2 font-mono text-xs break-all"
>
{path}
</div>
))}
</div>
) : (
<div className="text-muted-foreground rounded border border-dashed px-3 py-6 text-center text-sm">
未配置 SQLite 来源路径
</div>
)}
</section>
<section className="rounded-lg border bg-white p-5 dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5 text-sm">
<span className="font-medium">开始时间</span>
<Input
type="datetime-local"
value={startAt}
onChange={(e) => setStartAt(e.target.value)}
/>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="font-medium">结束时间</span>
<Input
type="datetime-local"
value={endAt}
onChange={(e) => setEndAt(e.target.value)}
/>
</label>
</div>
<div className="mt-5 flex justify-end">
<Button
onClick={handleStart}
disabled={running || !(sources?.source_paths.length ?? 0)}
>
{running && <Loader2Icon className="size-4 animate-spin" />}
开始同步
</Button>
</div>
</section>
{task && (
<section className="rounded-lg border bg-white p-5 dark:border-[#213D5E] dark:bg-[#02244C]">
<div className="mb-4 flex items-center justify-between">
<div className="text-sm font-medium">任务状态</div>
<Badge variant={statusVariant(task.status)}>{task.status}</Badge>
</div>
<div className="grid gap-3 text-sm sm:grid-cols-4">
<div className="rounded border p-3">
<div className="text-muted-foreground text-xs">总线程</div>
<div className="mt-1 text-lg font-semibold tabular-nums">
{task.total_threads}
</div>
</div>
<div className="rounded border p-3">
<div className="text-muted-foreground text-xs">已复制</div>
<div className="mt-1 text-lg font-semibold tabular-nums">
{task.copied_threads}
</div>
</div>
<div className="rounded border p-3">
<div className="text-muted-foreground text-xs">已跳过</div>
<div className="mt-1 text-lg font-semibold tabular-nums">
{task.skipped_threads}
</div>
</div>
<div className="rounded border p-3">
<div className="text-muted-foreground text-xs">跳过时间段</div>
<div className="mt-1 text-lg font-semibold tabular-nums">
{task.skipped_ranges}
</div>
</div>
</div>
{(task.message || task.error) && (
<div className="mt-4 rounded border bg-muted/40 px-3 py-2 text-sm">
{task.error || task.message}
</div>
)}
</section>
)}
</div>
);
}
+42
View File
@@ -17,6 +17,7 @@ import {
} from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context";
import { TodoList } from "@/components/workspace/todo-list";
import { getAccessToken } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import { useModels } from "@/core/models/hooks";
import { useNotification } from "@/core/notification/hooks";
@@ -33,6 +34,13 @@ import {
} from "./iframe-chat-submit";
type BrainyChatStatus = "started" | "finished";
type BrainyAuthTokenMessage = {
source: "brainy-iframe";
type: "brainy:auth-token";
payload: {
accessToken: string;
};
};
type BrainyChatInterruptMessage = {
source?: string;
type?: string;
@@ -57,6 +65,18 @@ function postBrainyChatStatus(status: BrainyChatStatus, requestId: string) {
);
}
function postBrainyAuthToken(accessToken: string) {
const message: BrainyAuthTokenMessage = {
source: "brainy-iframe",
type: "brainy:auth-token",
payload: {
accessToken,
},
};
window.parent?.postMessage(message, "*");
}
export default function IframeChatPage() {
const routeBase = useRouteBase();
const router = useRouter();
@@ -79,6 +99,28 @@ export default function IframeChatPage() {
setMounted(true);
}, []);
useEffect(() => {
let lastToken: string | undefined;
const emitAccessToken = () => {
const accessToken = getAccessToken();
if (!accessToken || accessToken === lastToken) {
return;
}
lastToken = accessToken;
postBrainyAuthToken(accessToken);
};
emitAccessToken();
window.addEventListener("focus", emitAccessToken);
window.addEventListener("storage", emitAccessToken);
return () => {
window.removeEventListener("focus", emitAccessToken);
window.removeEventListener("storage", emitAccessToken);
};
}, []);
useEffect(() => {
if (isNewThread) {
setHasStartedConversation(false);
+1 -1
View File
@@ -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);
+32 -5
View File
@@ -1,4 +1,4 @@
"use client";
"use client";
import {
ArrowLeftIcon,
@@ -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>
@@ -0,0 +1,238 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import {
BarChart3,
CheckCircle,
ChevronDown,
ChevronRight,
ExternalLink,
FileText,
Loader2,
} from "lucide-react";
import { useState } from "react";
import ReactMarkdown from "react-markdown";
import { useParams } from "react-router-dom";
import remarkGfm from "remark-gfm";
import {
getPublicDraftShareSnapshot,
type PublicDraftSeatDelivery,
} from "@/core/roundtable-draft-shares/api";
import { markdownComponents } from "@/roundtable-planning/components/markdown-components";
/** 各席位最终交付 —— 折叠手风琴(仿 HighFidelityReport 第二区块)。 */
function SeatDeliveries({ seats }: { seats: PublicDraftSeatDelivery[] }) {
const [expanded, setExpanded] = useState<Set<number>>(() => new Set());
const toggle = (i: number) =>
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(i)) next.delete(i);
else next.add(i);
return next;
});
if (seats.length === 0) {
return (
<p className="text-[11px] text-slate-400 italic">本次会商暂无子智能体交付。</p>
);
}
return (
<div className="space-y-2">
{seats.map((seat, idx) => {
const open = expanded.has(idx);
return (
<div key={`${seat.sender}-${idx}`} className="border border-slate-200 rounded-xl overflow-hidden">
<button
type="button"
onClick={() => toggle(idx)}
className="w-full flex items-center justify-between gap-2 px-3 py-2.5 bg-slate-50 hover:bg-slate-100 transition text-left"
>
<div className="flex items-center gap-2 min-w-0">
{open ? (
<ChevronDown className="w-3.5 h-3.5 text-slate-500 shrink-0" />
) : (
<ChevronRight className="w-3.5 h-3.5 text-slate-500 shrink-0" />
)}
<span className="text-[11.5px] font-bold text-slate-800 truncate">
{seat.sender || "未命名席位"}
</span>
</div>
<span className="text-[10px] text-slate-400 font-mono shrink-0">
{seat.content.length} 字
</span>
</button>
{open && (
<div className="px-4 py-3 text-xs text-slate-700 leading-relaxed border-t border-slate-100 bg-white">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{seat.content || "(空)"}
</ReactMarkdown>
</div>
)}
</div>
);
})}
</div>
);
}
/**
* Public, read-only roundtable-result viewer.
*
* Reachable at `#/roundtable-share/<code>` with NO authentication — anyone with
* the link can open it. Two views, driven by the `report` prop / route:
*
* - landing (`#/roundtable-share/<code>`): a read-only result page mirroring the
* in-app Step 3「最终方案展示」— 总控最终结论 + 各席位最终交付 + a 「查看结果绘制」
* button that opens the full report in a NEW TAB (when one was generated).
* - report (`#/roundtable-share/<code>/report`): the Step 3 report (a
* self-contained HTML document) rendered full-screen inside a sandboxed
* `iframe` (no `allow-scripts`), so the agent-generated HTML can never execute
* code in the app origin.
*/
export default function PublicRoundtableSharePage({
report = false,
}: {
report?: boolean;
}) {
const { shareCode } = useParams<{ shareCode: string }>();
const { data, isLoading, isError } = useQuery({
queryKey: ["public-roundtable-share", shareCode],
queryFn: () => getPublicDraftShareSnapshot(shareCode!),
enabled: !!shareCode,
retry: false,
refetchOnWindowFocus: false,
});
if (isLoading) {
return (
<div className="bg-background text-muted-foreground flex h-screen items-center justify-center gap-2 text-sm">
<Loader2 className="size-4 animate-spin" />
正在加载分享内容…
</div>
);
}
if (isError || !data) {
return (
<div className="bg-background flex h-screen flex-col items-center justify-center gap-2 px-6 text-center">
<p className="text-lg font-medium">分享不可用</p>
<p className="text-muted-foreground text-sm">
该分享链接不存在、已被撤销,或不是只读分享。
</p>
</div>
);
}
// ── 报告页:整页沙箱 iframe 展示第 3 步结果绘制 ──────────────────────────────
if (report) {
return (
<div className="bg-background flex h-screen flex-col">
<header className="flex shrink-0 items-center gap-2 border-b px-6 py-3">
<h1 className="truncate text-base font-semibold">
{data.title || "未命名会商"}
</h1>
<span className="bg-muted text-muted-foreground shrink-0 rounded-full px-2 py-0.5 text-xs">
结果绘制 · 只读
</span>
</header>
<main className="min-h-0 flex-1">
{data.has_report ? (
<iframe
// No `allow-scripts` — the report is self-contained (inline CSS +
// inline SVG charts) and must never run as a script in the app origin.
sandbox=""
title={data.title || "会商结果报告"}
srcDoc={data.html}
className="size-full border-0 bg-white"
/>
) : (
<div className="text-muted-foreground flex h-full flex-col items-center justify-center gap-2 px-6 text-center text-sm">
<p className="text-base font-medium text-foreground">暂无结果报告</p>
<p>这次会商还没有生成第三步「结果绘制」的方案报告。</p>
</div>
)}
</main>
</div>
);
}
// ── 落地页:总控最终结论 + 各席位最终交付 + 查看结果绘制(新标签打开报告页)─────
const reportUrl = `${window.location.origin}${window.location.pathname}#/roundtable-share/${shareCode}/report`;
return (
<div className="bg-[#fef6f6] flex h-screen flex-col text-slate-800">
<header className="flex shrink-0 flex-col gap-1 border-b border-slate-100 bg-white px-6 py-3">
<div className="flex items-center gap-2">
<h1 className="truncate text-base font-semibold">
{data.title || "未命名会商"}
</h1>
<span className="bg-slate-100 text-slate-500 shrink-0 rounded-full px-2 py-0.5 text-xs">
只读分享
</span>
</div>
<p className="text-slate-400 text-xs">
这是一份多智能体会商的结果分享(只读)。
</p>
</header>
<main className="min-h-0 flex-1 overflow-y-auto">
<div className="mx-auto w-full max-w-6xl space-y-6 p-6">
{/* 1. 结果绘制 —— 新标签打开报告页 */}
<section className="bg-white border border-slate-200 rounded-2xl p-5 shadow-[0_2px_8px_rgba(0,0,0,0.02)]">
<h4 className="text-xs font-black text-slate-900 uppercase tracking-widest mb-3 flex items-center gap-2">
<BarChart3 className="w-4 h-4 text-primary" />
结果绘制
</h4>
{data.has_report ? (
<a
href={reportUrl}
target="_blank"
rel="noopener noreferrer"
className="bg-primary text-white hover:bg-[var(--brand-900)] inline-flex items-center gap-2 rounded-xl px-5 py-2.5 text-sm font-semibold transition shadow-sm shadow-primary/10"
>
<BarChart3 className="size-4" />
查看结果绘制
<ExternalLink className="size-3.5 opacity-80" />
</a>
) : (
<p className="text-[11px] text-slate-400 italic">
这次会商还没有生成第三步「结果绘制」的方案报告。
</p>
)}
</section>
{/* 2. 总控最终结论 */}
<section className="bg-white border border-slate-200 rounded-2xl p-5 shadow-[0_2px_8px_rgba(0,0,0,0.02)]">
<h4 className="text-xs font-black text-slate-900 uppercase tracking-widest mb-3 flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-emerald-500 shrink-0" />
总控最终结论
</h4>
{data.consensus_text ? (
<div className="text-xs text-slate-700 leading-relaxed">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{data.consensus_text}
</ReactMarkdown>
</div>
) : (
<p className="text-[11px] text-slate-400 italic">暂无总控结论。</p>
)}
</section>
{/* 3. 各席位最终交付 */}
<section className="bg-white border border-slate-200 rounded-2xl p-5 shadow-[0_2px_8px_rgba(0,0,0,0.02)]">
<h4 className="text-xs font-black text-slate-900 uppercase tracking-widest mb-3 flex items-center gap-2">
<FileText className="w-4 h-4 text-primary" />
各席位最终交付
<span className="text-[10px] font-mono font-semibold text-slate-400 bg-slate-100 px-1.5 py-0.5 rounded">
{data.seat_deliveries.length}
</span>
</h4>
<SeatDeliveries seats={data.seat_deliveries} />
</section>
</div>
</main>
</div>
);
}
@@ -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" />
正在加载文件内容...
@@ -1,7 +1,7 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Activity, ChevronDown, ChevronLeft, Download, Edit3, FileText, Heart, Loader2, Save, Code2, Trash2, X } from "lucide-react";
import { Activity, ChevronDown, ChevronLeft, Download, Edit3, FileText, Heart, Loader2, Maximize2, Save, Code2, Trash2, X } from "lucide-react";
import { useParams, useSearchParams } from "react-router-dom";
import { useRouteNavigate as useNavigate } from "@/core/route-base";
import { toast } from "sonner";
@@ -9,6 +9,7 @@ import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
@@ -18,6 +19,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 +99,33 @@ function RenderPreview({ file }: { file: ScheduledTaskResultFileContent }) {
/>
);
}
return <pre className="whitespace-pre-wrap text-sm">{file.content}</pre>;
return <pre className="whitespace-pre-wrap break-words 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"
/>
);
type FullscreenPreview =
| { mode: "html"; title: string; html: string }
| { mode: "markdown"; title: string; content: string }
| { mode: "text"; title: string; content: string }
| { mode: "file"; title: string; file: ScheduledTaskResultFileContent };
function FullscreenPreviewContent({ preview }: { preview: FullscreenPreview }) {
if (preview.mode === "html") {
return <HtmlPreviewFrame html={preview.html} className="h-full w-full border-0 bg-white" />;
}
if (preview.mode === "markdown") {
return (
<MarkdownContent
className="max-w-none"
content={preview.content || "无正文内容"}
isLoading={false}
rehypePlugins={[]}
/>
);
}
if (preview.mode === "file") {
return <RenderPreview file={preview.file} />;
}
return <pre className="whitespace-pre-wrap break-words text-sm">{preview.content || "无正文内容"}</pre>;
}
type EditState = { draft: string; saving: boolean };
@@ -162,9 +174,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>
@@ -233,6 +245,7 @@ export default function ScheduledTaskRunDetailPage() {
const [filesLoading, setFilesLoading] = useState(false);
const [showMeta, setShowMeta] = useState(false);
const [edits, setEdits] = useState<Record<number, EditState | null>>({});
const [fullscreenPreview, setFullscreenPreview] = useState<FullscreenPreview | null>(null);
const canEditRun = run?.can_edit === true;
const canDeleteRun = canEditRun;
@@ -270,7 +283,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 +317,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) {
@@ -424,13 +456,33 @@ export default function ScheduledTaskRunDetailPage() {
};
return (
<WorkspaceContainer>
<>
<Dialog open={fullscreenPreview !== null} onOpenChange={(open) => !open && setFullscreenPreview(null)}>
<DialogContent className="flex h-[96vh] w-[96vw] max-w-none flex-col gap-0 p-0 sm:max-w-none">
<DialogHeader className="shrink-0 border-b px-4 py-3 pr-12">
<DialogTitle className="truncate text-base">
{fullscreenPreview?.title ?? "预览"}
</DialogTitle>
</DialogHeader>
{fullscreenPreview && (
<div
className={cn(
"min-h-0 flex-1",
fullscreenPreview.mode === "html" ? "" : "overflow-auto p-4",
)}
>
<FullscreenPreviewContent preview={fullscreenPreview} />
</div>
)}
</DialogContent>
</Dialog>
<WorkspaceContainer>
<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 +522,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 +554,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" />
@@ -540,6 +592,23 @@ export default function ScheduledTaskRunDetailPage() {
{isHtmlRun && (
<TabsContent value="html" className="m-0 p-4">
<div className="mb-3 flex flex-wrap items-center justify-end gap-2">
<Button
size="sm"
variant="outline"
onClick={() =>
htmlContent &&
setFullscreenPreview({
mode: "html",
title: result.title || "HTML 预览",
html: htmlContent,
})
}
disabled={!htmlContent}
title="全屏查看 HTML"
>
<Maximize2 className="size-4" />
全屏
</Button>
<Button
size="sm"
variant="outline"
@@ -565,6 +634,24 @@ export default function ScheduledTaskRunDetailPage() {
</TabsContent>
)}
<TabsContent value="body" className="m-0 p-4">
<div className="mb-3 flex justify-end">
<Button
size="sm"
variant="outline"
onClick={() =>
setFullscreenPreview({
mode: result.format === "markdown" || result.format === "mixed" ? "markdown" : "text",
title: result.title || "正文预览",
content: result.content || "无正文内容",
})
}
disabled={!result.content}
title="全屏查看正文"
>
<Maximize2 className="size-4" />
全屏
</Button>
</div>
{result.format === "markdown" || result.format === "mixed" ? (
<MarkdownContent
className="max-w-none"
@@ -573,7 +660,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 +671,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 +683,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 || "附件"}
@@ -604,6 +693,24 @@ export default function ScheduledTaskRunDetailPage() {
</div>
</div>
<div className="flex flex-wrap gap-2">
{file.previewable && !isEditing && (
<Button
size="sm"
variant="outline"
onClick={() =>
setFullscreenPreview({
mode: "file",
title: file.name,
file,
})
}
disabled={!file.content}
title="全屏查看文件"
>
<Maximize2 className="size-4" />
全屏
</Button>
)}
{isMarkdown && (
<Button
size="sm"
@@ -679,7 +786,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>
@@ -714,6 +821,7 @@ export default function ScheduledTaskRunDetailPage() {
</Card>
</div>
</WorkspaceBody>
</WorkspaceContainer>
</WorkspaceContainer>
</>
);
}
+166 -136
View File
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import {
AlertCircle,
BellOff,
@@ -44,7 +45,6 @@ import { TagChipList } from "@/components/workspace/tags/tag-chip";
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
import { useAgents } from "@/core/agents";
import { getStoredAuth } from "@/core/auth";
import { useModels } from "@/core/models/hooks";
import {
downloadScheduledTaskRun,
@@ -113,13 +113,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 +135,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"
@@ -184,7 +187,6 @@ export default function ScheduledTasksPage() {
const [subscribeTarget, setSubscribeTarget] = useState<PublishedScheduledTask | null>(null);
const [subscribeNotify, setSubscribeNotify] = useState(false);
const [tagPickerTask, setTagPickerTask] = useState<ScheduledTask | null>(null);
const isAdmin = useMemo(() => getStoredAuth()?.system_role === "admin", []);
const sortedTasks = useMemo(
() =>
@@ -242,11 +244,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 +274,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 +307,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 +318,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 +333,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 +372,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 +511,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"
@@ -518,19 +550,17 @@ export default function ScheduledTasksPage() {
<TagChipList tags={task.tags!} className="mt-3" />
)}
<div className="mt-3 flex flex-wrap gap-2">
{isAdmin && (
<Button
size="icon"
variant="outline"
title="编辑标签"
onClick={(event) => {
event.stopPropagation();
setTagPickerTask(task);
}}
>
<TagsIcon className="size-4" />
</Button>
)}
<Button
size="icon"
variant="outline"
title="编辑标签"
onClick={(event) => {
event.stopPropagation();
setTagPickerTask(task);
}}
>
<TagsIcon className="size-4" />
</Button>
{isOwner ? (
<>
<Button
@@ -563,8 +593,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,10 +744,27 @@ 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">
<CardTitle>执行记录</CardTitle>
<div className="flex min-w-0 items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<CardTitle>执行记录</CardTitle>
{selectedTask && (
<Button
size="sm"
variant="outline"
className="h-7 gap-1 px-2 text-xs"
title="在新标签打开该任务的公开页"
onClick={() => {
const url = `${window.location.origin}${window.location.pathname}#/public/scheduled-tasks/${encodeURIComponent(selectedTask.task_id)}`;
window.open(url, "_blank", "noopener,noreferrer");
}}
>
<Globe className="size-3.5" />
公开页
</Button>
)}
</div>
<Select value={runsTimeFilter} onValueChange={(v) => setRunsTimeFilter(v as typeof runsTimeFilter)}>
<SelectTrigger className="h-7 w-28 text-xs">
<SelectValue />
@@ -731,7 +777,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 +816,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 +978,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 +990,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 +1144,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>
@@ -1129,7 +1159,7 @@ export default function ScheduledTasksPage() {
</DialogContent>
</Dialog>
{isAdmin && tagPickerTask && (
{tagPickerTask && (
<TagPickerDialog
open={!!tagPickerTask}
onOpenChange={(open) => !open && setTagPickerTask(null)}
+73 -60
View File
@@ -1,44 +1,47 @@
import { Navigate, Route, Routes } from "react-router-dom";
import { Toaster } from "sonner";
import { SidebarProvider } from "@/components/ui/sidebar";
import { getStoredAuth } from "@/core/auth";
import { RouteBaseProvider } from "@/core/route-base";
import { SidebarProvider } from "@/components/ui/sidebar";
import GeneralQA from "@/strategy-components/components/chat-qa/GeneralQA";
import { ChatRuntime } from "./WorkspaceRoutes";
import AgentsPage from "./AgentsPage";
import ChatsPage from "./ChatsPage";
import ChatPage from "./ChatPage";
import NewAgentPage from "./NewAgentPage";
import FanganTyPage from "@/strategy-components/components/task-components/FanganTy";
import SentimentAnalyzes from "@/strategy-components/components/task-components/SentimentAnalyzes";
import TaskManagement from "@/strategy-components/components/task-components/TaskManagement";
import TaskPgFrom from "@/strategy-components/components/task-components/TaskPgFrom";
import TaskXkFrom from "@/strategy-components/components/task-components/TaskXkFrom";
import TaskAnalyzes from "@/strategy-components/components/task-components/TaskAnalyzes";
import XingdongTyPage from "@/strategy-components/components/task-components/XingdongTy";
import XXTyPage from "@/strategy-components/components/task-components/XXTy";
import { ActionAnalyzes } from "@/strategy-components/components/task-components/ActionAnalyzes";
import KnowledgeBaseResourceManagement from "@/strategy-components/components/resource-management/KnowledgeBaseResourceManagement";
import NetworkActorResourceManagement from "@/strategy-components/components/resource-management/NetworkActorResourceManagement";
import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage";
import "@/roundtable-planning/styles/roundtable-planning.css";
import AgentChatPage from "./AgentChatPage";
import SkillsPage from "./SkillsPage";
import SkillEvolutionPage from "./SkillEvolutionPage";
import MemoryPage from "./MemoryPage";
import ScheduledTasksPage from "./ScheduledTasksPage";
import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
import PrivateSquareSettingsPage from "./PrivateSquareSettingsPage";
import LlmMetricsPage from "./LlmMetricsPage";
import UsersPage from "./UsersPage";
import AdminScheduledTasksPage from "./AdminScheduledTasksPage";
import AdminUserActivityPage from "./AdminUserActivityPage";
import TagsPage from "./TagsPage";
import AgentsPage from "./AgentsPage";
import ChatPage from "./ChatPage";
import ChatsPage from "./ChatsPage";
import AdminUserLeaderboardPage from "./AdminUserLeaderboardPage";
import AdminUserLeaderboardSettingsPage from "./AdminUserLeaderboardSettingsPage";
import LightAppNewNotePage from "./LightAppNewNotePage";
import LightAppNoteListPage from "./LightAppNoteListPage";
import LightAppNoteQAPage from "./LightAppNoteQAPage";
import TaskXkFrom from "@/strategy-components/components/task-components/TaskXkFrom";
import TaskManagement from "@/strategy-components/components/task-components/TaskManagement";
import TaskPgFrom from "@/strategy-components/components/task-components/TaskPgFrom";
import FanganTyPage from "@/strategy-components/components/task-components/FanganTy";
import XingdongTyPage from "@/strategy-components/components/task-components/XingdongTy";
import XXTyPage from "@/strategy-components/components/task-components/XXTy";
import SentimentAnalyzes from "@/strategy-components/components/task-components/SentimentAnalyzes";
import TaskAnalyzes from "@/strategy-components/components/task-components/TaskAnalyzes";
import { ActionAnalyzes } from "@/strategy-components/components/task-components/ActionAnalyzes";
import NetworkActorResourceManagement from "@/strategy-components/components/resource-management/NetworkActorResourceManagement";
import KnowledgeBaseResourceManagement from "@/strategy-components/components/resource-management/KnowledgeBaseResourceManagement";
import MemoryPage from "./MemoryPage";
import NewAgentPage from "./NewAgentPage";
import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
import PrivateSquareSettingsPage from "./PrivateSquareSettingsPage";
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
import ScheduledTasksPage from "./ScheduledTasksPage";
import SkillEvolutionPage from "./SkillEvolutionPage";
import SkillsPage from "./SkillsPage";
import TagsPage from "./TagsPage";
import UsersPage from "./UsersPage";
import LlmMetricsPage from "./LlmMetricsPage";
import AdminScheduledTasksPage from "./AdminScheduledTasksPage";
import AdminUserActivityPage from "./AdminUserActivityPage";
import { ChatRuntime } from "./WorkspaceRoutes";
export default function StrategyRoutes() {
return (
@@ -48,21 +51,27 @@ export default function StrategyRoutes() {
<Routes>
<Route index element={<Navigate to="qa/general" replace />} />
{/* 问答中心 */}
<Route path="qa/general" element={<GeneralQA />} />
<Route
path="qa/expert-qa"
element={
<GeneralQA
chatHeadTitle="专家问答"
chatDesign="支持单轮RAG、多轮RAG、深度检索及结果对比"
chatHeadTitle="涓撳闂瓟"
chatDesign="鏀寔鍗曡疆RAG銆佸杞甊AG銆佹繁搴︽绱㈠強缁撴灉瀵规瘮"
/>
}
/>
{/* 任务管理 */}
<Route
path="qa/multi-agent-roundtable"
element={
<ChatRuntime>
<RoundtablePlanningPage />
</ChatRuntime>
}
/>
<Route path="task-management" element={<TaskManagement />}>
<Route index element={<Navigate to="xk-task-jc" replace />} />
{/* XK任务 */}
<Route
path="xk-task-jc"
element={<TaskXkFrom gopath="#/pageIndie/pageHome/pageSwrw" />}
@@ -71,7 +80,6 @@ export default function StrategyRoutes() {
path="xk-task-dispatch"
element={<TaskXkFrom gopath="#/pageIndie/pageJczl" />}
/>
{/* pg中任务 */}
<Route
path="effect-task-pg"
element={<TaskPgFrom gopath="task" />}
@@ -84,12 +92,11 @@ export default function StrategyRoutes() {
path="effect-info-pg"
element={<TaskPgFrom gopath="info" />}
/>
{/* COH中任务 */}
<Route
path="plan-task-sentiment"
element={
<SentimentAnalyzes
title="3QFX"
title="三情分析"
category="规划任务"
subCategory="规划任务"
/>
@@ -99,7 +106,7 @@ export default function StrategyRoutes() {
path="plan-task/plan-task-fx"
element={
<TaskAnalyzes
title="任务FX"
title="任务分析"
category="规划任务"
subCategory="规划任务"
/>
@@ -110,17 +117,16 @@ export default function StrategyRoutes() {
element={
<ActionAnalyzes
title="xdfx"
category="规划任务"
subCategory="规划任务"
category="瑙勫垝浠诲姟"
subCategory="瑙勫垝浠诲姟"
/>
}
/>
{/* MNTY任务 */}
<Route path="mnty-task-mechanism" element={<XingdongTyPage />} />
<Route path="mnty-task-plan" element={<FanganTyPage />} />
<Route path="mnty-task-zsty" element={<XXTyPage />} />
</Route>
{/* 资源管理 */}
<Route
path="resource-management/resource-network-actor"
element={<NetworkActorResourceManagement />}
@@ -131,16 +137,13 @@ export default function StrategyRoutes() {
/>
<Route
path="resource-management/resource-evaluation-index"
element={
<TaskXkFrom gopath="#/pageIndie/pageZyjc/pageZyzl" />
}
element={<TaskXkFrom gopath="#/pageIndie/pageZyjc/pageZyzl" />}
/>
{/* 轻应用 */}
<Route path="light-app/new-note" element={<LightAppNewNotePage />} />
<Route path="light-app/notes" element={<LightAppNoteListPage />} />
<Route path="light-app/note-qa" element={<LightAppNoteQAPage />} />
{/* 对话 */}
<Route path="chats" element={<ChatsPage />} />
<Route
path="chats/:thread_id"
@@ -151,7 +154,6 @@ export default function StrategyRoutes() {
}
/>
{/* 智能体管理 */}
<Route path="agents" element={<AgentsPage />} />
<Route path="agents/new" element={<NewAgentPage />} />
<Route
@@ -167,12 +169,10 @@ export default function StrategyRoutes() {
}
/>
{/* 技能 / 记忆 */}
<Route path="skills" element={<SkillsPage />} />
<Route path="skills/evolution" element={<SkillEvolutionPage />} />
<Route path="memory" element={<MemoryPage />} />
{/* 定时任务 */}
<Route
path="scheduled"
element={<Navigate to="/page/strategy/scheduled/tasks" replace />}
@@ -183,7 +183,6 @@ export default function StrategyRoutes() {
element={<ScheduledTaskRunDetailPage />}
/>
{/* 设置 / 管理 — 权限与 workspace 侧一致,非 admin 重定向到 chats/new */}
<Route
path="settings/recommended-questions"
element={
@@ -244,6 +243,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={
@@ -256,13 +275,7 @@ export default function StrategyRoutes() {
/>
<Route
path="admin/tags"
element={
getStoredAuth()?.system_role === "admin" ? (
<TagsPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
element={<TagsPage />}
/>
</Routes>
</div>
+16 -7
View File
@@ -25,6 +25,7 @@ import AdminSkillsPage from './AdminSkillsPage';
import ScheduledTaskRunDetailPage from './ScheduledTaskRunDetailPage';
import ScheduledTasksPage from './ScheduledTasksPage';
import LlmMetricsPage from './LlmMetricsPage';
import CheckpointMigrationPage from './CheckpointMigrationPage';
import MemoryPage from './MemoryPage';
import PromptPrefixSettingsPage from './PromptPrefixSettingsPage';
import PrivateSquareSettingsPage from './PrivateSquareSettingsPage';
@@ -419,13 +420,9 @@ export default function WorkspaceRoutes({
<Route
path="admin/tags"
element={
getStoredAuth()?.system_role === 'admin' ? (
<WorkspaceLayout {...layoutProps}>
<TagsPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
<WorkspaceLayout {...layoutProps}>
<TagsPage />
</WorkspaceLayout>
}
/>
<Route
@@ -476,6 +473,18 @@ export default function WorkspaceRoutes({
)
}
/>
<Route
path="settings/checkpoint-migration"
element={
getStoredAuth()?.system_role === 'admin' ? (
<WorkspaceLayout {...layoutProps}>
<CheckpointMigrationPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
</Routes>
</RouteBaseProvider>
);
@@ -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",
},
]);
});
+48 -1
View File
@@ -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 } : {}),
},
}
: {}),
};
}