# Conflicts:
#	frontend-web/src/components/workspace/workspace-nav-menu.tsx
#	offline-backend-20260512/backend/app/gateway/routers/multi_agent.py
This commit is contained in:
fengxiangdebaozi committed 2026-07-26 20:55:50 +08:00
commit 239b798c5d
42 files changed
+2416 -282

No files matched your search

+1 -1
View File
@@ -707,4 +707,4 @@ export default function IframeChatPage() {
</ChatBox>
</ThreadContext.Provider>
);
}
}
@@ -13,6 +13,21 @@ import {
useUpdateModelConcurrencySettings,
} from "@/core/system-settings/hooks";
function cleanLimitMap(values: Record<string, string>, label: string): Record<string, number> | null {
const cleaned: Record<string, number> = {};
for (const [name, rawValue] of Object.entries(values)) {
const trimmed = rawValue.trim();
if (!trimmed) continue;
const parsed = Math.round(Number(trimmed));
if (!Number.isFinite(parsed) || parsed < 1) {
toast.error(`${label}「${name}」必须是大于 0 的整数`);
return null;
}
cleaned[name] = Math.min(parsed, 10000);
}
return cleaned;
}
export default function ModelConcurrencySettingsPage({
embedded = false,
}: {
@@ -24,13 +39,16 @@ export default function ModelConcurrencySettingsPage({
const [enabled, setEnabled] = useState(false);
const [defaultLimit, setDefaultLimit] = useState(10);
const [userDefaultLimit, setUserDefaultLimit] = useState(0);
const [modelLimits, setModelLimits] = useState<Record<string, string>>({});
const [userModelLimits, setUserModelLimits] = useState<Record<string, string>>({});
const initializedRef = useRef(false);
useEffect(() => {
if (!data || initializedRef.current) return;
setEnabled(data.enabled);
setDefaultLimit(data.default_limit);
setUserDefaultLimit(data.user_default_limit ?? 0);
setModelLimits(
Object.fromEntries(
Object.entries(data.model_limits ?? {}).map(([name, limit]) => [
@@ -39,39 +57,45 @@ export default function ModelConcurrencySettingsPage({
]),
),
);
setUserModelLimits(
Object.fromEntries(
Object.entries(data.user_model_limits ?? {}).map(([name, limit]) => [
name,
String(limit),
]),
),
);
initializedRef.current = true;
}, [data]);
const modelNames = useMemo(() => models.map((model) => model.name), [models]);
const handleModelLimitChange = (modelName: string, value: string) => {
setModelLimits((prev) => ({ ...prev, [modelName]: value }));
};
const handleSave = async () => {
const nextDefault = Math.max(1, Math.min(Math.round(defaultLimit) || 10, 10000));
const cleaned: Record<string, number> = {};
for (const [name, rawValue] of Object.entries(modelLimits)) {
const trimmed = rawValue.trim();
if (!trimmed) continue;
const parsed = Math.round(Number(trimmed));
if (!Number.isFinite(parsed) || parsed < 1) {
toast.error(`模型 ${name} 的并发上限必须是大于 0 的整数`);
return;
}
cleaned[name] = Math.min(parsed, 10000);
}
const nextUserDefault = Math.max(0, Math.min(Math.round(userDefaultLimit) || 0, 10000));
const cleanedModelLimits = cleanLimitMap(modelLimits, "模型总上限");
if (!cleanedModelLimits) return;
const cleanedUserModelLimits = cleanLimitMap(userModelLimits, "单用户上限");
if (!cleanedUserModelLimits) return;
try {
await update.mutateAsync({
enabled,
default_limit: nextDefault,
model_limits: cleaned,
model_limits: cleanedModelLimits,
user_default_limit: nextUserDefault,
user_model_limits: cleanedUserModelLimits,
});
setDefaultLimit(nextDefault);
setUserDefaultLimit(nextUserDefault);
setModelLimits(
Object.fromEntries(
Object.entries(cleaned).map(([name, limit]) => [name, String(limit)]),
Object.entries(cleanedModelLimits).map(([name, limit]) => [name, String(limit)]),
),
);
setUserModelLimits(
Object.fromEntries(
Object.entries(cleanedUserModelLimits).map(([name, limit]) => [name, String(limit)]),
),
);
toast.success("已保存");
@@ -85,7 +109,7 @@ export default function ModelConcurrencySettingsPage({
className={
embedded
? "flex w-full flex-col gap-6"
: "mx-auto flex h-full min-h-0 w-full max-w-3xl flex-col gap-6 overflow-y-auto px-6 py-10"
: "mx-auto flex h-full min-h-0 w-full max-w-4xl flex-col gap-6 overflow-y-auto px-6 py-10"
}
>
<div>
@@ -93,7 +117,7 @@ export default function ModelConcurrencySettingsPage({
模型并发限制
</h1>
<p className="text-muted-foreground mt-1 text-sm">
按模型限制同时进行中的问答数。达到上限后,新请求会收到“当前使用人数较多,请稍等一下再试”的提示。
按模型控制系统总并发,也可以限制单个用户对某个模型同时发起的调用数。达到上限时会在发送问题后立即提示,问题会保留在对话中。
</p>
</div>
@@ -102,42 +126,59 @@ export default function ModelConcurrencySettingsPage({
<Loader2Icon className="text-muted-foreground size-6 animate-spin" />
</div>
) : error ? (
<div className="text-destructive rounded-xl border border-dashed py-16 text-center text-sm">
<div className="text-destructive rounded-lg border border-dashed py-16 text-center text-sm">
加载失败:{error.message}
</div>
) : (
<div className="flex flex-col gap-5 rounded-xl border bg-white px-5 py-5 dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
<div className="flex flex-col gap-5 rounded-lg border bg-white px-5 py-5 dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
<div className="flex items-center justify-between gap-4">
<div>
<div className="text-sm font-medium">开启限制</div>
<div className="text-muted-foreground mt-0.5 text-xs">
关闭后,不再按模型并发数拦截问答请求。
关闭后,不再按模型或用户并发数拦截问答请求。
</div>
</div>
<Switch checked={enabled} onCheckedChange={setEnabled} />
</div>
<div className="flex flex-col gap-1.5">
<p className="text-sm font-medium">默认上限</p>
<p className="text-muted-foreground text-xs">
未单独配置的模型,使用这个并发上限。
</p>
<Input
type="number"
min={1}
max={10000}
value={defaultLimit}
disabled={!enabled}
onChange={(e) => setDefaultLimit(Number(e.target.value))}
className="w-32"
/>
<div className="grid gap-4 md:grid-cols-2">
<div className="flex flex-col gap-1.5">
<p className="text-sm font-medium">模型默认总上限</p>
<p className="text-muted-foreground text-xs">
未单独配置的模型,使用这个系统总并发上限。
</p>
<Input
type="number"
min={1}
max={10000}
value={defaultLimit}
disabled={!enabled}
onChange={(e) => setDefaultLimit(Number(e.target.value))}
className="w-32"
/>
</div>
<div className="flex flex-col gap-1.5">
<p className="text-sm font-medium">单用户默认上限</p>
<p className="text-muted-foreground text-xs">
同一用户对同一模型的默认并发上限。填 0 表示不启用默认用户限制。
</p>
<Input
type="number"
min={0}
max={10000}
value={userDefaultLimit}
disabled={!enabled}
onChange={(e) => setUserDefaultLimit(Number(e.target.value))}
className="w-32"
/>
</div>
</div>
<div className="flex flex-col gap-3">
<div>
<p className="text-sm font-medium">按模型单独设置</p>
<p className="text-muted-foreground mt-0.5 text-xs">
留空表示使用默认上限。
留空表示使用对应默认值;单用户上限填空且默认值为 0 时,不限制该维度。
</p>
</div>
@@ -150,24 +191,42 @@ export default function ModelConcurrencySettingsPage({
modelNames.map((modelName) => (
<div
key={modelName}
className="flex items-center justify-between gap-4 rounded-lg border px-4 py-3"
className="grid gap-3 rounded-lg border px-4 py-3 md:grid-cols-[minmax(0,1fr)_8rem_8rem]"
>
<div className="min-w-0">
<div className="truncate text-sm font-medium">{modelName}</div>
<div className="text-muted-foreground text-xs">
留空时使用默认上限 {defaultLimit || 10}
默认总上限 {defaultLimit || 10};默认单用户上限 {userDefaultLimit || "不限制"}
</div>
</div>
<Input
type="number"
min={1}
max={10000}
value={modelLimits[modelName] ?? ""}
disabled={!enabled}
onChange={(e) => handleModelLimitChange(modelName, e.target.value)}
className="w-32"
placeholder={String(defaultLimit || 10)}
/>
<label className="flex flex-col gap-1">
<span className="text-muted-foreground text-xs">模型总上限</span>
<Input
type="number"
min={1}
max={10000}
value={modelLimits[modelName] ?? ""}
disabled={!enabled}
onChange={(e) =>
setModelLimits((prev) => ({ ...prev, [modelName]: e.target.value }))
}
placeholder={String(defaultLimit || 10)}
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-muted-foreground text-xs">单用户上限</span>
<Input
type="number"
min={1}
max={10000}
value={userModelLimits[modelName] ?? ""}
disabled={!enabled}
onChange={(e) =>
setUserModelLimits((prev) => ({ ...prev, [modelName]: e.target.value }))
}
placeholder={userDefaultLimit > 0 ? String(userDefaultLimit) : "不限制"}
/>
</label>
</div>
))
)}
+11 -83
View File
@@ -3,9 +3,7 @@
import {
ArrowLeftIcon,
CheckCircleIcon,
InfoIcon,
SaveIcon,
SearchIcon,
} from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
@@ -38,6 +36,7 @@ import {
type SetupAgentApprovalPayload,
} from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context";
import { SkillTransferSelector } from "@/components/workspace/agents/skill-transfer-selector";
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
import { getAPIClient } from "@/core/api";
import type { Agent } from "@/core/agents";
@@ -51,7 +50,7 @@ import { getStoredAuth } from "@/core/auth";
import { useLiveAccount } from "@/core/auth/use-live-account";
import { useI18n } from "@/core/i18n/hooks";
import { useModels } from "@/core/models/hooks";
import { isAgentSkillCandidate, type Skill } from "@/core/skills";
import { isAgentSkillCandidate } from "@/core/skills";
import { useSkills } from "@/core/skills/hooks";
import { useThreadStream } from "@/core/threads/hooks";
import { uuid } from "@/core/utils/uuid";
@@ -98,7 +97,6 @@ export default function NewAgentPage() {
const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
const [isSkillSelectionTouched, setIsSkillSelectionTouched] = useState(false);
const [detailSkillName, setDetailSkillName] = useState<string | null>(null);
const [skillSearch, setSkillSearch] = useState("");
// 恢复已有 bootstrap 会话时该 thread 已存在,无需再 create(置为 true,跳过创建)。
const [threadPrepared, setThreadPrepared] = useState(!!resumeThreadId);
const { skills, isLoading: isSkillsLoading } = useSkills();
@@ -123,17 +121,6 @@ export default function NewAgentPage() {
[skills, storedAuth],
);
const filteredSkills = useMemo(() => {
const q = skillSearch.trim().toLowerCase();
if (!q) return enabledSkills;
return enabledSkills.filter(
(skill) =>
skill.name.toLowerCase().includes(q) ||
(skill.name_zh ?? "").toLowerCase().includes(q) ||
(skill.description ?? "").toLowerCase().includes(q),
);
}, [enabledSkills, skillSearch]);
useEffect(() => {
if (isSkillSelectionTouched) {
return;
@@ -380,14 +367,10 @@ export default function NewAgentPage() {
[handleChatSubmit],
);
const toggleSkill = useCallback((skill: Skill) => {
const handleSkillSelectionChange = useCallback((next: string[]) => {
setIsSkillSelectionTouched(true);
setFormError("");
setSelectedSkills((prev) =>
prev.includes(skill.name)
? prev.filter((name) => name !== skill.name)
: [...prev, skill.name],
);
setSelectedSkills(next);
}, []);
const busy = isCreatingAgent || isSavingDraft || thread.isLoading;
@@ -624,68 +607,13 @@ export default function NewAgentPage() {
已选 {selectedSkills.length} 个
</span>
</div>
{enabledSkills.length > 0 && (
<div className="relative">
<SearchIcon className="text-muted-foreground absolute left-2 top-1/2 size-3.5 -translate-y-1/2" />
<Input
placeholder="搜索技能名称或描述…"
value={skillSearch}
onChange={(e) => setSkillSearch(e.currentTarget.value)}
className="h-8 pl-7 text-xs"
/>
</div>
)}
<div className="max-h-[240px] overflow-y-auto rounded-md border p-2 dark:border-[#1B477E] dark:bg-[#022248]">
{isSkillsLoading ? (
<p className="text-muted-foreground text-sm">技能加载中...</p>
) : enabledSkills.length === 0 ? (
<p className="text-muted-foreground text-sm">
当前没有可用技能。
</p>
) : filteredSkills.length === 0 ? (
<p className="text-muted-foreground text-sm">
没有匹配的技能。
</p>
) : (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{filteredSkills.map((skill) => {
const checked = selectedSkills.includes(skill.name);
return (
<div
key={skill.name}
className="flex w-full min-w-0 items-center"
>
<Button
type="button"
size="sm"
variant={checked ? "default" : "outline"}
onClick={() => toggleSkill(skill)}
title={skill.description}
className={cn(
"h-8 min-w-0 flex-1 rounded-r-none",
!checked && "text-muted-foreground",
)}
>
<span className="truncate">
{skill.name_zh?.trim() ? skill.name_zh : skill.name}
</span>
</Button>
<Button
type="button"
size="icon"
variant={checked ? "default" : "outline"}
onClick={() => setDetailSkillName(skill.name)}
title={t.settings.skills.viewDetailAction}
className="h-8 w-7 rounded-l-none border-l-0"
>
<InfoIcon className="size-3.5" />
</Button>
</div>
);
})}
</div>
)}
</div>
<SkillTransferSelector
skills={enabledSkills}
selected={selectedSkills}
onChange={handleSkillSelectionChange}
isLoading={isSkillsLoading}
onViewDetail={setDetailSkillName}
/>
</div>
<SkillDetailDialog
@@ -2,6 +2,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
BookOpen,
Check,
ChevronLeft,
ChevronRight,
@@ -23,6 +24,11 @@ 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 {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Dialog,
DialogContent,
@@ -42,10 +48,12 @@ import {
} from "@/components/ui/tooltip";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { HtmlPreviewFrame } from "@/components/html-preview-frame";
import { ReportMarkdownView } from "@/components/wiki/ReportMarkdownView";
import { getBackendBaseURL } from "@/core/config";
import { usePublicBackground } from "@/core/embed/background";
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
import { reasoningPlugins } from "@/core/streamdown";
import { createMarkdownAnchorResolver } from "@/core/utils/markdown";
import { cn } from "@/lib/utils";
type PublicTask = {
@@ -264,6 +272,190 @@ function stripTimestampSuffix(title: string): string {
return cleaned.trim() || title;
}
function stripMarkdownToText(markdown: string): string {
return markdown
.replace(/```[\s\S]*?```/g, " ")
.replace(/~~~[\s\S]*?~~~/g, " ")
.replace(/!\[[^\]]*\]\([^)]+\)/g, " ")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/^#{1,6}\s+/gm, "")
.replace(/^[>\-*+\d.)\s]+/gm, "")
.replace(/[`*_~|]/g, "")
.replace(/<[^>]+>/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function getMarkdownExcerpt(markdown: string): string {
const text = stripMarkdownToText(markdown);
if (!text) return "";
return text.length > 180 ? `${text.slice(0, 180)}...` : text;
}
function countReadableWords(markdown: string): number {
const text = stripMarkdownToText(markdown);
if (!text) return 0;
const cjk = text.match(/[\u4e00-\u9fff]/g)?.length ?? 0;
const words = text.replace(/[\u4e00-\u9fff]/g, " ").match(/[A-Za-z0-9]+/g)?.length ?? 0;
return cjk + words;
}
function selectorForId(id: string): string {
const css = window.CSS;
if (css?.escape) return `#${css.escape(id)}`;
return `[id="${id.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"]`;
}
function KnowledgeMarkdownView({
content,
title,
subtitle,
className,
}: {
content: string;
title?: string;
subtitle?: string;
className?: string;
isLoading?: boolean;
remarkPlugins?: unknown;
rehypePlugins?: unknown;
}) {
const rootRef = useRef<HTMLDivElement>(null);
const anchorResolver = useMemo(() => createMarkdownAnchorResolver(content), [content]);
const headings = useMemo(
() => anchorResolver.headings.filter((item) => item.level >= 1 && item.level <= 3),
[anchorResolver],
);
const [activeHeadingId, setActiveHeadingId] = useState<string | null>(null);
const excerpt = useMemo(() => getMarkdownExcerpt(content), [content]);
const sectionCount = headings.length;
const wordCount = useMemo(() => countReadableWords(content), [content]);
useEffect(() => {
setActiveHeadingId(headings[0]?.id ?? null);
}, [headings]);
useEffect(() => {
const root = rootRef.current;
if (!root || headings.length === 0) return;
const targets = headings
.map((item) => root.querySelector<HTMLElement>(selectorForId(item.id)))
.filter((item): item is HTMLElement => !!item);
if (targets.length === 0) return;
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((entry) => entry.isIntersecting)
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)[0];
if (visible?.target.id) {
setActiveHeadingId(visible.target.id);
}
},
{
root: root.closest("[data-radix-scroll-area-viewport]") ?? null,
rootMargin: "-96px 0px -65% 0px",
threshold: [0, 1],
},
);
targets.forEach((target) => observer.observe(target));
return () => observer.disconnect();
}, [headings, content]);
const scrollToHeading = useCallback((id: string) => {
const root = rootRef.current;
const target =
root?.querySelector<HTMLElement>(selectorForId(id)) ?? document.getElementById(id);
setActiveHeadingId(id);
target?.scrollIntoView({ behavior: "smooth", block: "start" });
}, []);
return (
<div
ref={rootRef}
className={cn(
"grid min-h-0 gap-5 lg:grid-cols-[minmax(0,1fr)_300px]",
className,
)}
>
<article className="min-w-0 overflow-hidden rounded-lg border border-slate-200/80 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-950">
<div className="border-b border-slate-100 bg-slate-50/90 px-6 py-5 dark:border-slate-800 dark:bg-slate-900/70 md:px-8">
<div className="flex flex-wrap items-center gap-2 text-xs text-slate-500 dark:text-slate-400">
<span className="inline-flex items-center gap-1 rounded-md bg-white px-2 py-1 font-medium text-slate-600 ring-1 ring-slate-200 dark:bg-slate-950 dark:text-slate-300 dark:ring-slate-800">
<BookOpen className="size-3.5" />
知识梳理
</span>
{subtitle && <span className="truncate">{subtitle}</span>}
</div>
{title && (
<h1 className="mt-3 text-xl font-semibold leading-snug text-slate-950 dark:text-slate-50 md:text-2xl">
{title}
</h1>
)}
{excerpt && (
<p className="mt-3 max-w-4xl text-sm leading-7 text-slate-600 dark:text-slate-300">
{excerpt}
</p>
)}
<div className="mt-4 flex flex-wrap gap-2 text-xs text-slate-500 dark:text-slate-400">
<span className="rounded-md bg-white px-2 py-1 ring-1 ring-slate-200 dark:bg-slate-950 dark:ring-slate-800">
{sectionCount || 1} 个章节
</span>
<span className="rounded-md bg-white px-2 py-1 ring-1 ring-slate-200 dark:bg-slate-950 dark:ring-slate-800">
约 {wordCount} 字
</span>
</div>
</div>
<div className="wiki-markdown public-report-markdown mx-auto w-full max-w-[980px] px-6 py-7 md:px-10 md:py-9">
<MarkdownContent
className="max-w-none"
content={content}
isLoading={false}
remarkPlugins={reasoningPlugins.remarkPlugins}
rehypePlugins={reasoningPlugins.rehypePlugins}
/>
</div>
</article>
<aside className="hidden min-w-0 lg:block">
<div className="sticky top-4 rounded-lg border border-slate-200/80 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-950">
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-slate-800 dark:text-slate-100">
<List className="size-4" />
内容目录
</div>
{headings.length > 0 ? (
<nav className="max-h-[calc(100vh-210px)] space-y-1 overflow-auto pr-1">
{headings.map((item) => (
<button
key={item.id}
type="button"
className={cn(
"block w-full truncate rounded-md border-l-2 border-transparent px-2 py-1.5 text-left text-xs leading-5 text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-950 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-slate-50",
activeHeadingId === item.id &&
"border-primary bg-primary/10 font-medium text-primary dark:text-primary",
item.level === 2 && "pl-4",
item.level === 3 && "pl-7 text-slate-500 dark:text-slate-400",
)}
title={item.text}
onClick={() => scrollToHeading(item.id)}
>
{item.text}
</button>
))}
</nav>
) : (
<div className="rounded-md border border-dashed p-3 text-xs text-slate-500 dark:border-slate-800 dark:text-slate-400">
暂无标题目录
</div>
)}
</div>
</aside>
</div>
);
}
const BODY_TAB_VALUE = "__body__";
const HTML_TAB_VALUE = "__html__";
const RUN_LIST_COLLAPSED_WIDTH = 56;
@@ -688,12 +880,10 @@ function PublicFullscreenPreviewContent({ preview }: { preview: PublicFullscreen
}
if (preview.mode === "markdown") {
return (
<MarkdownContent
<ReportMarkdownView
className="max-w-none"
content={preview.content || "无正文内容"}
isLoading={false}
remarkPlugins={reasoningPlugins.remarkPlugins}
rehypePlugins={reasoningPlugins.rehypePlugins}
title={preview.title}
/>
);
}
@@ -701,12 +891,10 @@ function PublicFullscreenPreviewContent({ preview }: { preview: PublicFullscreen
const content = preview.file.content || "无文件内容";
if (isMarkdownFile(preview.file)) {
return (
<MarkdownContent
<ReportMarkdownView
className="max-w-none"
content={content}
isLoading={false}
remarkPlugins={reasoningPlugins.remarkPlugins}
rehypePlugins={reasoningPlugins.rehypePlugins}
title={preview.title}
/>
);
}
@@ -731,6 +919,7 @@ function publicFullscreenHref(taskId: string, runId: string, tab: ContentTab): s
function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) {
const queryClient = useQueryClient();
const [fullscreenPreview, setFullscreenPreview] = useState<PublicFullscreenPreview | null>(null);
const [runMetaOpen, setRunMetaOpen] = useState(false);
// HTML page run: render a sandboxed iframe preview instead of the MD tabs.
const isHtmlRun =
@@ -970,7 +1159,29 @@ function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) {
</TooltipContent>
)}
</Tooltip>
<p className="text-muted-foreground mt-1 text-xs">
<Collapsible open={runMetaOpen} onOpenChange={setRunMetaOpen} className="mt-2">
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground h-7 px-2 text-xs"
>
<ChevronRight
className={cn("size-3.5 transition-transform", runMetaOpen && "rotate-90")}
/>
运行信息
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="mt-2 grid gap-1 rounded-md border bg-muted/30 px-3 py-2 text-xs text-muted-foreground sm:grid-cols-3">
<div className="min-w-0 break-all">计划:{formatDate(run.scheduled_for)}</div>
<div>开始:{formatDate(run.started_at)}</div>
<div>完成:{formatDate(run.finished_at)}</div>
</div>
</CollapsibleContent>
</Collapsible>
<p className="hidden">
开始:{formatDate(run.started_at)} · 完成:{formatDate(run.finished_at)}
</p>
</div>
@@ -1122,12 +1333,14 @@ function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) {
无内容。点击「编辑」开始添加。
</div>
) : (
<MarkdownContent
<ReportMarkdownView
className="max-w-none pr-2"
content={activeSource}
isLoading={false}
remarkPlugins={reasoningPlugins.remarkPlugins}
rehypePlugins={reasoningPlugins.rehypePlugins}
title={
activeTabMeta?.kind === "file"
? stripExtension(activeTabMeta.fileName || "")
: run.result?.title
}
/>
)}
</ScrollArea>
@@ -20,6 +20,7 @@ import {
} from "@/components/workspace/workspace-container";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { HtmlPreviewFrame } from "@/components/html-preview-frame";
import { ReportMarkdownView } from "@/components/wiki/ReportMarkdownView";
import {
downloadScheduledTaskRun,
downloadScheduledTaskRunFile,
@@ -91,11 +92,12 @@ function RenderPreview({ file }: { file: ScheduledTaskResultFileContent }) {
}
if (isMarkdownFile(file)) {
return (
<MarkdownContent
<ReportMarkdownView
className="max-w-none"
content={file.content}
isLoading={false}
rehypePlugins={[]}
title={file.name.replace(/\.(md|markdown)$/i, "")}
badgeLabel="结果文件"
compactHeader
/>
);
}
@@ -114,11 +116,11 @@ function FullscreenPreviewContent({ preview }: { preview: FullscreenPreview }) {
}
if (preview.mode === "markdown") {
return (
<MarkdownContent
<ReportMarkdownView
className="max-w-none"
content={preview.content || "无正文内容"}
isLoading={false}
rehypePlugins={[]}
title={preview.title}
badgeLabel="正文预览"
/>
);
}
@@ -653,11 +655,12 @@ export default function ScheduledTaskRunDetailPage() {
</Button>
</div>
{result.format === "markdown" || result.format === "mixed" ? (
<MarkdownContent
<ReportMarkdownView
className="max-w-none"
content={result.content || "无正文内容"}
isLoading={false}
rehypePlugins={[]}
title={result.title || "正文预览"}
subtitle={`计划时间:${formatDate(run.scheduled_for)}`}
badgeLabel="任务报告"
/>
) : (
<pre className="whitespace-pre-wrap break-words text-sm">