大模型速度增加提示

This commit is contained in:
吉鑫升 committed 2026-05-14 19:12:14 +08:00
1 parent 4e764350a4
commit 3ea33e1dee
52 files changed
+4673 -176

No files matched your search

+32 -3
View File
@@ -1,8 +1,9 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { type PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { usePromptPrefixSettings } from "@/core/system-settings/hooks";
import { ArtifactTrigger } from "@/components/workspace/artifacts";
import {
ChatBox,
@@ -39,6 +40,8 @@ export default function ChatPage() {
const [settings, setSettings] = useThreadSettings(threadId);
const [mounted, setMounted] = useState(false);
const { tokenUsageEnabled } = useModels();
const { data: promptPrefixSettings } = usePromptPrefixSettings();
const [promptPrefixOn, setPromptPrefixOn] = useState(false);
useSpecificChatMode();
useEffect(() => {
@@ -48,6 +51,7 @@ export default function ChatPage() {
useEffect(() => {
if (isNewThread) {
setHasStartedConversation(false);
setPromptPrefixOn(false);
}
}, [isNewThread, threadId]);
@@ -87,18 +91,42 @@ export default function ChatPage() {
const handleSubmit = useCallback(
(message: PromptInputMessage) => {
const shouldApplyPrefix =
!hasStartedConversation &&
promptPrefixOn &&
promptPrefixSettings?.enabled === true;
setHasStartedConversation(true);
window.dispatchEvent(
new CustomEvent("workspace:sandbox-resume-autoscroll"),
);
void sendMessage(threadId, message);
const messageToSend = shouldApplyPrefix
? {
...message,
text: `${promptPrefixSettings?.prompt_prefix ?? ""}\n\n${message.text}`,
}
: message;
void sendMessage(
threadId,
messageToSend,
shouldApplyPrefix ? { prompt_prefix_enabled: true } : undefined,
);
},
[sendMessage, threadId],
[sendMessage, threadId, hasStartedConversation, promptPrefixOn, promptPrefixSettings],
);
const handleStop = useCallback(async () => {
await thread.stop();
}, [thread]);
const promptPrefixProp = useMemo(() => {
if (!promptPrefixSettings?.enabled) return undefined;
if (hasStartedConversation) return undefined;
return {
label: promptPrefixSettings.switch_label || "前缀",
on: promptPrefixOn,
onToggle: () => setPromptPrefixOn((v) => !v),
};
}, [promptPrefixSettings, hasStartedConversation, promptPrefixOn]);
const messageListPaddingBottom = showFollowups
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM
@@ -184,6 +212,7 @@ export default function ChatPage() {
onContextChange={(context) =>
setSettings("context", context)
}
promptPrefix={promptPrefixProp}
onFollowupsVisibilityChange={setShowFollowups}
onSubmit={handleSubmit}
onStop={handleStop}
+458
View File
@@ -0,0 +1,458 @@
"use client";
import { Download, Loader2 } from "lucide-react";
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
downloadLlmMetricsExcel,
type LlmMetricsFilters,
} from "@/core/llm-metrics/api";
import { useLlmMetrics } from "@/core/llm-metrics/hooks";
import { cn } from "@/lib/utils";
const PAGE_SIZE = 50;
type StatusFilter = "all" | "success" | "error";
function displayUser(row: { user_name?: string | null; user_id?: string | null }): string {
// Prefer the resolved email's local part (sent by the backend). Fall back to
// the raw UUID only when no account record exists for the recorded user_id —
// showing a UUID is uglier but preserves the link to who triggered the call.
if (row.user_name && row.user_name.trim()) return row.user_name.trim();
const id = (row.user_id ?? "").trim();
return id || "—";
}
function formatDate(value?: string | null): string {
if (!value) return "—";
try {
return new Date(value).toLocaleString();
} catch {
return value;
}
}
function formatNumber(value?: number | null, fractionDigits = 0): string {
if (value === null || value === undefined || Number.isNaN(value)) return "—";
return value.toLocaleString(undefined, {
minimumFractionDigits: fractionDigits,
maximumFractionDigits: fractionDigits,
});
}
function formatDuration(value?: number | null): string {
if (value === null || value === undefined || Number.isNaN(value)) return "—";
if (value === 0) return "0 ms";
if (value < 1000) return `${value} ms`;
if (value < 60_000) return `${(value / 1000).toFixed(1)} s`;
const minutes = Math.floor(value / 60_000);
const seconds = Math.round((value % 60_000) / 1000);
return `${minutes}m ${seconds}s`;
}
function speedColor(tps?: number | null): string {
if (tps == null) return "#9ca3af";
if (tps >= 50) return "#16a34a";
if (tps >= 30) return "#65a30d";
if (tps >= 15) return "#eab308";
if (tps >= 5) return "#f97316";
return "#ef4444";
}
export default function LlmMetricsPage() {
const [page, setPage] = useState(0);
const [model, setModel] = useState("");
const [user, setUser] = useState("");
const [status, setStatus] = useState<StatusFilter>("all");
const [since, setSince] = useState("");
const [until, setUntil] = useState("");
const [exporting, setExporting] = useState(false);
const filters = useMemo<LlmMetricsFilters>(() => {
const out: LlmMetricsFilters = {};
if (model.trim()) out.model_name = model.trim();
if (user.trim()) out.user_name = user.trim();
if (status !== "all") out.status = status;
if (since) out.since = new Date(since).toISOString();
if (until) out.until = new Date(until).toISOString();
return out;
}, [model, user, status, since, until]);
const query = useMemo(
() => ({ ...filters, limit: PAGE_SIZE, offset: page * PAGE_SIZE }),
[filters, page],
);
const { data, isLoading, error, isFetching } = useLlmMetrics(query);
const handleExport = async () => {
setExporting(true);
try {
await downloadLlmMetricsExcel(filters);
toast.success("已导出为 Excel");
} catch (e) {
toast.error(e instanceof Error ? e.message : "导出失败");
} finally {
setExporting(false);
}
};
const resetFilters = () => {
setModel("");
setUser("");
setStatus("all");
setSince("");
setUntil("");
setPage(0);
};
const items = data?.items ?? [];
const total = data?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
// Hide columns that are empty for every visible row. Keeps the table dense
// when older records lack a tokens breakdown — better to drop the column
// than fill the page with placeholder dashes.
const visibleColumns = useMemo(() => {
type Col = {
id: string;
header: string;
align?: "left" | "right";
alwaysShow?: boolean;
hasValue: (row: typeof items[number]) => boolean;
render: (row: typeof items[number]) => React.ReactNode;
title?: (row: typeof items[number]) => string | undefined;
className?: string;
};
const columns: Col[] = [
{
id: "time",
header: "时间",
alwaysShow: true,
hasValue: () => true,
render: (row) => formatDate(row.created_at),
},
{
id: "user",
header: "用户",
alwaysShow: true,
hasValue: () => true,
render: (row) => displayUser(row),
title: (row) => row.user_id ?? undefined,
},
{
id: "model",
header: "模型",
alwaysShow: true,
hasValue: () => true,
render: (row) => row.model_name ?? "—",
},
{
id: "agent",
header: "Agent",
hasValue: (row) => Boolean(row.agent_name),
render: (row) => row.agent_name ?? "—",
},
{
id: "input",
header: "输入",
align: "right",
hasValue: (row) => row.input_tokens != null && row.input_tokens > 0,
render: (row) => formatNumber(row.input_tokens),
className: "tabular-nums",
},
{
id: "output",
header: "输出",
align: "right",
hasValue: (row) => row.output_tokens != null && row.output_tokens > 0,
render: (row) => formatNumber(row.output_tokens),
className: "tabular-nums",
},
{
id: "total",
header: "总 Token",
align: "right",
hasValue: (row) => row.total_tokens != null && row.total_tokens > 0,
render: (row) => formatNumber(row.total_tokens),
className: "tabular-nums",
},
{
id: "duration",
header: "耗时",
align: "right",
alwaysShow: true,
hasValue: (row) => row.duration_ms != null && row.duration_ms > 0,
render: (row) => formatDuration(row.duration_ms),
title: (row) => `${row.duration_ms} ms`,
className: "tabular-nums",
},
{
id: "tokens_per_sec",
header: "tok/s",
align: "right",
hasValue: (row) => row.tokens_per_sec != null && row.tokens_per_sec > 0,
render: (row) =>
row.tokens_per_sec != null ? (
<span
className="font-mono tabular-nums"
style={{ color: speedColor(row.tokens_per_sec) }}
>
{row.tokens_per_sec.toFixed(1)}
</span>
) : (
"—"
),
},
{
id: "status",
header: "状态",
alwaysShow: true,
hasValue: () => true,
render: (row) => (
<Badge
variant={row.status === "success" ? "default" : "destructive"}
>
{row.status === "success" ? "成功" : "失败"}
</Badge>
),
},
{
id: "error",
header: "错误",
hasValue: (row) => Boolean(row.error_type || row.error_message),
render: (row) => row.error_type ?? row.error_message ?? "—",
title: (row) => row.error_message ?? undefined,
className: "text-muted-foreground max-w-[260px] truncate",
},
];
return columns.filter(
(col) => col.alwaysShow || items.some((row) => col.hasValue(row)),
);
}, [items]);
const colSpan = visibleColumns.length || 1;
return (
<div className="mx-auto flex w-full max-w-6xl flex-col gap-4 px-6 py-8">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-xl font-semibold">大模型调用统计</h1>
<p className="text-muted-foreground mt-1 text-sm">
记录每一次大模型的实际调用,包括 token 数、耗时、估算速度和失败原因。
后台无条件记录,与前端速度指示器开关无关。
</p>
</div>
<Button onClick={handleExport} disabled={exporting}>
{exporting ? (
<Loader2 className="mr-1 size-4 animate-spin" />
) : (
<Download className="mr-1 size-4" />
)}
导出 Excel
</Button>
</div>
<div className="grid gap-3 rounded-xl border bg-white p-4 md:grid-cols-5">
<div className="flex flex-col gap-1">
<label className="text-muted-foreground text-xs">模型</label>
<Input
value={model}
placeholder="按模型名筛选"
onChange={(e) => {
setModel(e.target.value);
setPage(0);
}}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-muted-foreground text-xs">用户名</label>
<Input
value={user}
placeholder="按用户名匹配(@ 前部分)"
onChange={(e) => {
setUser(e.target.value);
setPage(0);
}}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-muted-foreground text-xs">状态</label>
<Select
value={status}
onValueChange={(value) => {
setStatus(value as StatusFilter);
setPage(0);
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部</SelectItem>
<SelectItem value="success">成功</SelectItem>
<SelectItem value="error">失败</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1">
<label className="text-muted-foreground text-xs">开始时间</label>
<Input
type="datetime-local"
value={since}
onChange={(e) => {
setSince(e.target.value);
setPage(0);
}}
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-muted-foreground text-xs">结束时间</label>
<Input
type="datetime-local"
value={until}
onChange={(e) => {
setUntil(e.target.value);
setPage(0);
}}
/>
</div>
<div className="md:col-span-5 flex justify-end">
<Button variant="outline" size="sm" onClick={resetFilters}>
清空筛选
</Button>
</div>
</div>
<div className="rounded-xl border bg-white">
<div className="overflow-x-auto">
<table className="w-full min-w-[1080px] text-sm">
<thead className="bg-muted/60 text-muted-foreground">
<tr>
{visibleColumns.map((col) => (
<Th
key={col.id}
className={col.align === "right" ? "text-right" : undefined}
>
{col.header}
</Th>
))}
</tr>
</thead>
<tbody>
{isLoading && (
<tr>
<td colSpan={colSpan} className="py-10 text-center">
<Loader2 className="text-muted-foreground inline size-5 animate-spin" />
</td>
</tr>
)}
{!isLoading && error && (
<tr>
<td colSpan={colSpan} className="text-destructive py-10 text-center text-sm">
加载失败:{error.message}
</td>
</tr>
)}
{!isLoading && !error && items.length === 0 && (
<tr>
<td colSpan={colSpan} className="text-muted-foreground py-10 text-center text-sm">
暂无符合条件的调用记录。
</td>
</tr>
)}
{items.map((row) => (
<tr key={row.id} className="border-t">
{visibleColumns.map((col) => (
<Td
key={col.id}
className={cn(
col.align === "right" && "text-right",
col.className,
)}
title={col.title?.(row)}
>
{col.render(row)}
</Td>
))}
</tr>
))}
</tbody>
</table>
</div>
<div className="flex items-center justify-between border-t px-4 py-2 text-xs">
<span className="text-muted-foreground">
{isFetching && !isLoading ? "更新中..." : `共 ${total} 条`}
</span>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
disabled={page <= 0 || isLoading}
onClick={() => setPage((p) => Math.max(0, p - 1))}
>
上一页
</Button>
<span className="text-muted-foreground">
第 {page + 1} / {totalPages} 页
</span>
<Button
size="sm"
variant="outline"
disabled={page + 1 >= totalPages || isLoading}
onClick={() => setPage((p) => p + 1)}
>
下一页
</Button>
</div>
</div>
</div>
</div>
);
}
function Th({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<th
className={`px-3 py-2 text-left text-xs font-medium ${className ?? ""}`}
>
{children}
</th>
);
}
function Td({
children,
className,
title,
}: {
children: React.ReactNode;
className?: string;
title?: string;
}) {
return (
<td
className={`px-3 py-2 align-top text-sm ${className ?? ""}`}
title={title}
>
{children}
</td>
);
}
@@ -0,0 +1,125 @@
"use client";
import { Loader2Icon } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import {
usePromptPrefixSettings,
useUpdatePromptPrefixSettings,
} from "@/core/system-settings/hooks";
export default function PromptPrefixSettingsPage() {
const { data, isLoading, error } = usePromptPrefixSettings();
const update = useUpdatePromptPrefixSettings();
const [enabled, setEnabled] = useState(false);
const [switchLabel, setSwitchLabel] = useState("");
const [promptPrefix, setPromptPrefix] = useState("");
// Only seed the draft from server data once. Re-syncing every time react-query
// refetches (window focus, route remount, etc.) would silently overwrite the
// user's in-progress edits — and made the toggle look unresponsive: a click
// would flip state to true and the next refetch would reset it to false.
const initializedRef = useRef(false);
useEffect(() => {
if (!data || initializedRef.current) return;
setEnabled(data.enabled);
setSwitchLabel(data.switch_label);
setPromptPrefix(data.prompt_prefix);
initializedRef.current = true;
}, [data]);
const handleSave = async () => {
if (enabled && !switchLabel.trim()) {
toast.error("开启时必须填写开关名称");
return;
}
if (enabled && !promptPrefix.trim()) {
toast.error("开启时必须填写拼接的前缀内容");
return;
}
try {
await update.mutateAsync({
enabled,
switch_label: switchLabel,
prompt_prefix: promptPrefix,
});
toast.success("已保存");
} catch (e) {
toast.error(e instanceof Error ? e.message : "保存失败");
}
};
return (
<div className="mx-auto flex w-full max-w-2xl flex-col gap-6 px-6 py-10">
<div>
<h1 className="text-xl font-semibold">问答前缀配置</h1>
<p className="text-muted-foreground mt-1 text-sm">
管理员可在此配置问答页输入框中的「前缀开关」。开启后,用户在新会话中第一次提问时,
会将下方配置的前缀文本和原问题拼接后再发送给模型。
</p>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-16">
<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">
加载失败:{error.message}
</div>
) : (
<div className="flex flex-col gap-5 rounded-xl border bg-white px-5 py-5">
<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
value={switchLabel}
onChange={(e) => setSwitchLabel(e.target.value)}
placeholder="例:加入立场前缀"
/>
</div>
<div className="flex flex-col gap-1.5">
<p className="text-sm font-medium">拼接内容</p>
<p className="text-muted-foreground text-xs">
拼接到首条提问最前面的文字。例如:你需要站在中方角度,进行评价。
</p>
<Textarea
rows={4}
value={promptPrefix}
onChange={(e) => setPromptPrefix(e.target.value)}
placeholder="在新会话的第一次提问前自动加入这段文字"
/>
</div>
<div className="flex justify-end">
<Button onClick={handleSave} disabled={update.isPending}>
{update.isPending && (
<Loader2Icon className="mr-1.5 size-4 animate-spin" />
)}
保存
</Button>
</div>
</div>
)}
</div>
);
}
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { ChevronDown, Download, FileText, ListChecks, Trash2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { ChevronDown, Download, Edit3, FileText, ListChecks, Loader2, Save, Trash2, X } from "lucide-react";
import { useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner";
@@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import {
WorkspaceBody,
WorkspaceContainer,
@@ -22,9 +23,11 @@ import {
getScheduledTaskRunFileContent,
type ScheduledTaskResultFileContent,
type ScheduledTaskRun,
updateScheduledTaskRunFile,
useScheduledTaskActions,
useScheduledTaskRun,
} from "@/core/scheduled-tasks";
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
import { cn } from "@/lib/utils";
function formatDate(value?: string | null) {
@@ -91,6 +94,8 @@ function RenderPreview({ file }: { file: ScheduledTaskResultFileContent }) {
return <pre className="whitespace-pre-wrap text-sm">{file.content}</pre>;
}
type EditState = { draft: string; saving: boolean };
export default function ScheduledTaskRunDetailPage() {
const navigate = useNavigate();
const { run_id } = useParams();
@@ -100,6 +105,9 @@ export default function ScheduledTaskRunDetailPage() {
const [fileContents, setFileContents] = useState<ScheduledTaskResultFileContent[]>([]);
const [filesLoading, setFilesLoading] = useState(false);
const [showMeta, setShowMeta] = useState(false);
const [edits, setEdits] = useState<Record<number, EditState | null>>({});
const canEditRun = run?.can_edit === true;
const canDeleteRun = canEditRun;
const resultFiles = useMemo(() => result?.files ?? [], [result]);
const fileCount = resultFiles.length;
@@ -169,8 +177,82 @@ export default function ScheduledTaskRunDetailPage() {
}
};
const handleDownloadFileAsWord = async (file: ScheduledTaskResultFileContent) => {
// Prefer the in-progress draft if the user is editing the file.
const source = edits[file.index]?.draft ?? file.content ?? "";
if (!source) {
toast.error("文件内容未加载,无法转换为 Word");
return;
}
try {
await downloadMarkdownAsDocx(source, file.name, result?.title);
toast.success("Word 文件已生成");
} catch (error) {
toast.error(error instanceof Error ? error.message : "生成 Word 失败");
}
};
const handleEnterEdit = useCallback((file: ScheduledTaskResultFileContent) => {
setEdits((prev) => ({
...prev,
[file.index]: { draft: file.content ?? "", saving: false },
}));
}, []);
const handleCancelEdit = useCallback((index: number) => {
setEdits((prev) => ({ ...prev, [index]: null }));
}, []);
const handleSaveEdit = useCallback(
async (file: ScheduledTaskResultFileContent) => {
if (!run) return;
const current = edits[file.index];
if (!current) return;
setEdits((prev) => ({
...prev,
[file.index]: { ...current, saving: true },
}));
try {
const updated = await updateScheduledTaskRunFile(run.id, file.index, current.draft);
setFileContents((prev) =>
prev.map((entry) =>
entry.index === file.index
? { ...entry, content: current.draft, size: updated.size ?? entry.size }
: entry,
),
);
setEdits((prev) => ({ ...prev, [file.index]: null }));
toast.success(`文件 ${file.name} 已保存`);
} catch (error) {
setEdits((prev) => ({
...prev,
[file.index]: { ...current, saving: false },
}));
toast.error(error instanceof Error ? error.message : "保存失败");
}
},
[edits, run],
);
const handleDownloadBodyAsWord = async () => {
if (!result?.content) {
toast.error("正文为空,无法转换为 Word");
return;
}
try {
await downloadMarkdownAsDocx(
result.content,
result.title || "scheduled-task-result",
result.title || undefined,
);
toast.success("Word 文件已生成");
} catch (error) {
toast.error(error instanceof Error ? error.message : "生成 Word 失败");
}
};
const handleDeleteRun = async () => {
if (!run) return;
if (!run || !canDeleteRun) return;
try {
await actions.removeRun.mutateAsync(run.id);
toast.success("执行记录已删除");
@@ -206,10 +288,23 @@ export default function ScheduledTaskRunDetailPage() {
<Download className="size-3" />
下载全部
</Button>
<Button variant="destructive" size="sm" className="h-7 px-2 text-xs" onClick={handleDeleteRun} disabled={!run}>
<Trash2 className="size-3" />
删除
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={handleDownloadBodyAsWord}
disabled={!result?.content}
title="将正文 Markdown 导出为 .docx"
>
<FileText className="size-3" />
下载正文 Word
</Button>
{canDeleteRun && (
<Button variant="destructive" size="sm" className="h-7 px-2 text-xs" onClick={handleDeleteRun}>
<Trash2 className="size-3" />
删除
</Button>
)}
</div>
{/* 执行信息(可折叠) */}
{showMeta && run && (
@@ -280,26 +375,94 @@ export default function ScheduledTaskRunDetailPage() {
</div>
)}
<div className="flex flex-col gap-3">
{fileContents.map((file) => (
<div key={file.index} className="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="truncate text-sm font-medium">{file.name}</div>
<div className="text-muted-foreground text-xs">
{file.mime_type || file.kind || "附件"}
{typeof file.size === "number" ? ` · ${file.size} bytes` : ""}
{fileContents.map((file) => {
const isMarkdown = isMarkdownFile(file);
const editable = canEditRun && file.previewable;
const editState = edits[file.index] ?? null;
const isEditing = editState !== null;
return (
<div key={file.index} className="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="truncate text-sm font-medium">{file.name}</div>
<div className="text-muted-foreground text-xs">
{file.mime_type || file.kind || "附件"}
{typeof file.size === "number" ? ` · ${file.size} bytes` : ""}
</div>
</div>
<div className="flex flex-wrap gap-2">
{isMarkdown && (
<Button
size="sm"
variant="outline"
onClick={() => handleDownloadFileAsWord(file)}
disabled={!file.content && !editState?.draft}
title="将 Markdown 导出为 Word"
>
<FileText className="size-4" />
下载 Word
</Button>
)}
<Button size="sm" variant="outline" onClick={() => handleDownloadFile(file.index)}>
<Download className="size-4" />
下载
</Button>
{editable && !isEditing && (
<Button size="sm" variant="outline" onClick={() => handleEnterEdit(file)}>
<Edit3 className="size-4" />
编辑
</Button>
)}
{isEditing && (
<>
<Button
size="sm"
variant="outline"
onClick={() => handleCancelEdit(file.index)}
disabled={editState?.saving}
>
<X className="size-4" />
取消
</Button>
<Button
size="sm"
onClick={() => handleSaveEdit(file)}
disabled={editState?.saving}
>
{editState?.saving ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Save className="size-4" />
)}
保存
</Button>
</>
)}
</div>
</div>
<Button size="sm" variant="outline" onClick={() => handleDownloadFile(file.index)}>
<Download className="size-4" />
下载
</Button>
<div className="p-3">
{isEditing ? (
<Textarea
className="min-h-80 font-mono text-xs"
value={editState?.draft ?? ""}
disabled={editState?.saving}
onChange={(event) =>
setEdits((prev) => ({
...prev,
[file.index]: {
draft: event.target.value,
saving: prev[file.index]?.saving ?? false,
},
}))
}
/>
) : (
<RenderPreview file={file} />
)}
</div>
</div>
<div className="p-3">
<RenderPreview file={file} />
</div>
</div>
))}
);
})}
</div>
</TabsContent>
<TabsContent value="meta" className="m-0 p-3">
+108 -24
View File
@@ -73,39 +73,91 @@ const MINUTES = [0, 15, 30, 45];
const DATES = Array.from({ length: 31 }, (_, i) => i + 1);
function buildCron(cfg: ScheduleConfig): string {
// Keep the minute slot when the user picked one for daily/weekly/monthly too,
// so "每天 9:30" doesn't silently truncate to 9:00 on save → reload round trips.
const m = Number.isFinite(cfg.minute) ? cfg.minute : 0;
switch (cfg.freq) {
case "hourly":
return `${cfg.minute} * * * *`;
return `${m} * * * *`;
case "daily":
return `0 ${cfg.hour} * * *`;
return `${m} ${cfg.hour} * * *`;
case "weekly":
return `0 ${cfg.hour} * * ${cfg.weekday}`;
return `${m} ${cfg.hour} * * ${cfg.weekday}`;
case "monthly":
return `0 ${cfg.hour} ${cfg.date} * *`;
return `${m} ${cfg.hour} ${cfg.date} * *`;
}
}
function parseCron(cron: string): ScheduleConfig | null {
// Parse a 5-field cron expression into the form picker state.
//
// Bug we keep getting bitten by: the edit dialog used to call
// `parseCron(task.schedule_text)` and `schedule_text` may be the raw user
// input (free-text like "每天 9 点") rather than a real cron string. The
// fallback `DEFAULT_SCHEDULE_CONFIG` then made the picker silently
// reinterpret the task as "every day at 09:00" — or, when natural-language
// resolved to "0 * * * *" on the backend, the picker honestly reflected
// that as "每小时 0 分". Either way the user lost their original intent.
//
// Caller now prefers `task.cron_expr` (the canonical schedule produced by
// `_schedule_to_cron`); this function stays the single source of truth for
// cron → picker, and also handles the looser step-syntax variants
// (e.g. "0 [STAR]/3 * * *") we sometimes generate.
function parseCron(cron: string | null | undefined): ScheduleConfig | null {
if (!cron) return null;
const parts = cron.trim().split(/\s+/);
if (parts.length !== 5) return null;
const [min, hr, date, , wd] = parts;
const minute = parseInt(min ?? "", 10);
const hour = parseInt(hr ?? "", 10);
// Hourly: ``M * * * *`` — match before the daily branch so a minute-only
// cron isn't misread as daily.
if (hr === "*" && date === "*" && wd === "*") {
const minute = parseInt(min);
if (!isNaN(minute)) return { ...DEFAULT_SCHEDULE_CONFIG, freq: "hourly", minute };
if (!isNaN(minute)) {
return { ...DEFAULT_SCHEDULE_CONFIG, freq: "hourly", minute };
}
}
// ``0 */N * * *`` (every N hours): map to hourly with that minute; the
// picker doesn't model "every N hours" yet, so this is the safest fallback
// that at least doesn't silently round to 9:00.
if ((hr ?? "").startsWith("*/") && date === "*" && wd === "*") {
if (!isNaN(minute)) {
return { ...DEFAULT_SCHEDULE_CONFIG, freq: "hourly", minute };
}
}
if (date === "*" && wd === "*") {
const hour = parseInt(hr);
if (!isNaN(hour)) return { ...DEFAULT_SCHEDULE_CONFIG, freq: "daily", hour };
if (!isNaN(hour)) {
return {
...DEFAULT_SCHEDULE_CONFIG,
freq: "daily",
hour,
minute: isNaN(minute) ? 0 : minute,
};
}
}
if (date === "*" && wd !== "*") {
const hour = parseInt(hr);
const weekday = parseInt(wd);
if (!isNaN(hour) && !isNaN(weekday)) return { ...DEFAULT_SCHEDULE_CONFIG, freq: "weekly", hour, weekday };
const weekday = parseInt(wd ?? "", 10);
if (!isNaN(hour) && !isNaN(weekday)) {
return {
...DEFAULT_SCHEDULE_CONFIG,
freq: "weekly",
hour,
weekday,
minute: isNaN(minute) ? 0 : minute,
};
}
}
if (wd === "*" && date !== "*") {
const hour = parseInt(hr);
const d = parseInt(date);
if (!isNaN(hour) && !isNaN(d)) return { ...DEFAULT_SCHEDULE_CONFIG, freq: "monthly", hour, date: d };
const d = parseInt(date ?? "", 10);
if (!isNaN(hour) && !isNaN(d)) {
return {
...DEFAULT_SCHEDULE_CONFIG,
freq: "monthly",
hour,
date: d,
minute: isNaN(minute) ? 0 : minute,
};
}
}
return null;
}
@@ -191,6 +243,14 @@ function formatDate(value?: string | null) {
return value ? new Date(value).toLocaleString() : "暂无";
}
/** Render a creator-like identifier without the email domain part. */
function displayUsername(value?: string | null): string {
const raw = (value ?? "").trim();
if (!raw) return "";
const at = raw.indexOf("@");
return at === -1 ? raw : raw.slice(0, at);
}
function statusLabel(status?: string | null) {
switch (status) {
case "succeeded":
@@ -248,6 +308,7 @@ export default function ScheduledTasksPage() {
[tasks],
);
const selectedTask = sortedTasks.find((task) => task.task_id === selectedTaskId) ?? null;
const selectedTaskCanEditRuns = !!selectedTask && selectedTask.role !== "subscriber";
const { data: runs = [], isLoading: runsLoading } = useScheduledTaskRuns(selectedTaskId);
const filteredRuns = useMemo(() => {
@@ -292,7 +353,14 @@ export default function ScheduledTasksPage() {
prompt: task.prompt,
execution_agent_name: task.execution_agent_name ?? "",
});
setScheduleConfig(parseCron(task.schedule_text) ?? DEFAULT_SCHEDULE_CONFIG);
// Prefer the canonical cron_expr over the user-typed schedule_text: the
// latter may be natural language ("每天9点") that parseCron can't read,
// which is what made the dialog default back to "每天9点 → 每小时0分".
setScheduleConfig(
parseCron(task.cron_expr) ??
parseCron(task.schedule_text) ??
DEFAULT_SCHEDULE_CONFIG,
);
};
const closeDialog = () => {
@@ -451,6 +519,13 @@ export default function ScheduledTasksPage() {
<div>下次执行:{formatDate(task.next_run_at)}</div>
<div>上次执行:{formatDate(task.last_run_at)}</div>
<div>最近状态:{statusLabel(task.last_status)}</div>
{(task.creator_email || task.creator_user_id) && (
<div className="truncate">
创建者:
{displayUsername(task.creator_email) ||
task.creator_user_id}
</div>
)}
</div>
<div className="mt-3 flex flex-wrap gap-2">
{isOwner ? (
@@ -641,6 +716,13 @@ export default function ScheduledTasksPage() {
<p className="text-muted-foreground text-sm">
{selectedTask ? selectedTask.name : "请选择左侧任务"}
</p>
{selectedTask && (selectedTask.creator_email || selectedTask.creator_user_id) && (
<p className="text-muted-foreground text-xs">
创建者:
{displayUsername(selectedTask.creator_email) ||
selectedTask.creator_user_id}
</p>
)}
</CardHeader>
<CardContent className="min-h-0 flex-1 p-0">
<ScrollArea className="size-full">
@@ -700,14 +782,16 @@ export default function ScheduledTasksPage() {
<Download className="size-4" />
下载结果
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => handleDeleteRun(run.id)}
>
<Trash2 className="size-4" />
删除
</Button>
{selectedTaskCanEditRuns && run.can_edit !== false && (
<Button
size="sm"
variant="destructive"
onClick={() => handleDeleteRun(run.id)}
>
<Trash2 className="size-4" />
删除
</Button>
)}
</div>
</div>
))}
@@ -18,6 +18,8 @@ import AgentChatPage from "./AgentChatPage";
import NewAgentPage from "./NewAgentPage";
import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
import ScheduledTasksPage from "./ScheduledTasksPage";
import LlmMetricsPage from "./LlmMetricsPage";
import PromptPrefixSettingsPage from "./PromptPrefixSettingsPage";
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
function WorkspaceLayout({
@@ -158,6 +160,30 @@ export default function WorkspaceRoutes({
)
}
/>
<Route
path="settings/prompt-prefix"
element={
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<PromptPrefixSettingsPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="settings/llm-metrics"
element={
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<LlmMetricsPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
</Routes>
);
}