大模型速度增加提示
This commit is contained in:
1 parent
4e764350a4
commit
3ea33e1dee
52 files changed
+4673
-176
No files matched your search
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user