大模型速度增加提示

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

@@ -117,6 +117,7 @@ export function InputBox({
onFollowupsVisibilityChange,
onSubmit,
onStop,
promptPrefix,
...props
}: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & {
assistantId?: string | null;
@@ -145,6 +146,16 @@ export function InputBox({
onFollowupsVisibilityChange?: (visible: boolean) => void;
onSubmit?: (message: PromptInputMessage) => void;
onStop?: () => void;
/**
* Optional toggle rendered next to the model selector. Used for the
* admin-configurable prompt-prefix switch on the chat input. Omitting the
* prop hides the button entirely.
*/
promptPrefix?: {
label: string;
on: boolean;
onToggle: () => void;
};
}) {
const { t } = useI18n();
const searchParams = useSearchParams();
@@ -819,6 +830,34 @@ export function InputBox({
)}
</PromptInputTools>
<PromptInputTools className="gap-1.5">
{promptPrefix && (
<Tooltip
content={
promptPrefix.on
? `已开启:${promptPrefix.label}`
: `点击开启:${promptPrefix.label}`
}
>
<PromptInputButton
type="button"
onClick={promptPrefix.onToggle}
aria-pressed={promptPrefix.on}
className={cn(
"rounded-full border px-3 shadow-none transition-colors",
promptPrefix.on
? "border-[#d6a868] bg-[#fff3df] text-[#7a5417] hover:bg-[#ffe8c4]"
: "border-[#eee3d5] bg-[#fffaf5] text-[#8a7558] hover:bg-white",
)}
>
<span className="max-w-[160px] truncate text-xs font-normal">
{promptPrefix.label}
</span>
{promptPrefix.on && (
<CheckIcon className="ml-1 size-3.5 text-[#7a5417]" />
)}
</PromptInputButton>
</Tooltip>
)}
<ModelSelector
open={modelDialogOpen}
onOpenChange={setModelDialogOpen}
@@ -31,7 +31,10 @@ import { StreamingIndicator } from "../streaming-indicator";
import { MarkdownContent } from "./markdown-content";
import { MessageGroup } from "./message-group";
import { MessageListItem } from "./message-list-item";
import { MessageTokenUsageList } from "./message-token-usage";
import {
MessageMetricsProvider,
MessageTokenUsageList,
} from "./message-token-usage";
import { MessageListSkeleton } from "./skeleton";
import { SubtaskCard } from "./subtask-card";
@@ -69,6 +72,7 @@ export function MessageList({
className={cn("flex size-full flex-col justify-center", className)}
>
<ConversationContent className="mx-auto w-full max-w-(--container-width-lg) gap-8 pt-12">
<MessageMetricsProvider messages={messages} isLoading={thread.isLoading}>
{groupMessages(messages, (group) => {
if (group.type === "human" || group.type === "assistant") {
return group.messages.map((msg) => {
@@ -265,6 +269,7 @@ export function MessageList({
})}
{thread.isLoading && <StreamingIndicator className="my-4" />}
<div style={{ height: `${paddingBottom}px` }} />
</MessageMetricsProvider>
</ConversationContent>
</Conversation>
);
@@ -1,28 +1,98 @@
import type { Message } from "@langchain/langgraph-sdk";
import { CoinsIcon } from "lucide-react";
import { CoinsIcon, GaugeIcon } from "lucide-react";
import { useI18n } from "@/core/i18n/hooks";
import { formatTokenCount, getUsageMetadata } from "@/core/messages/usage";
import { useLocalSettings } from "@/core/settings";
import { cn } from "@/lib/utils";
import {
MessageMetricsContext,
useMessageMetric,
useMessageMetrics,
type MessageMetric,
} from "./use-message-metrics";
const FULL_SCALE_TOKENS_PER_SEC = 80;
function classifySpeed(tps: number): { color: string; label: string } {
if (tps >= 50) return { color: "#16a34a", label: "极快" };
if (tps >= 30) return { color: "#65a30d", label: "流畅" };
if (tps >= 15) return { color: "#eab308", label: "一般" };
if (tps >= 5) return { color: "#f97316", label: "偏慢" };
return { color: "#ef4444", label: "缓慢" };
}
function formatDuration(ms: number): string {
if (ms < 1000) return `${Math.round(ms)} ms`;
if (ms < 60_000) return `${(ms / 1000).toFixed(1)} s`;
const mins = Math.floor(ms / 60_000);
const secs = Math.round((ms % 60_000) / 1000);
return `${mins}m ${secs}s`;
}
function SpeedPill({ metric }: { metric: MessageMetric }) {
const fill = Math.max(0, Math.min(1, metric.tokensPerSec / FULL_SCALE_TOKENS_PER_SEC));
const { color, label } = classifySpeed(metric.tokensPerSec);
const display =
metric.tokensPerSec >= 100
? Math.round(metric.tokensPerSec).toString()
: metric.tokensPerSec.toFixed(1);
const prefix = metric.fromUsage ? "" : "~";
return (
<span
className="inline-flex items-center gap-1.5"
title={`${label} · ${metric.fromUsage ? "按 output_tokens 计算" : "字符估算 ≈ 2 字符 / token"}`}
>
<GaugeIcon className="size-3" style={{ color }} />
<span className="font-mono tabular-nums" style={{ color }}>
{prefix}
{display}
</span>
<span className="text-muted-foreground">tok/s</span>
<span className="relative inline-block h-1 w-12 overflow-hidden rounded-full bg-current/15 align-middle">
<span
className="absolute inset-y-0 left-0 rounded-full transition-[width,background-color] duration-200 ease-out"
style={{ width: `${fill * 100}%`, backgroundColor: color }}
/>
</span>
</span>
);
}
export function MessageTokenUsage({
className,
enabled = false,
isLoading = false,
message,
metric,
}: {
className?: string;
enabled?: boolean;
isLoading?: boolean;
message: Message;
metric?: MessageMetric;
}) {
const { t } = useI18n();
const [settings] = useLocalSettings();
const speedEnabled = settings.display.streamSpeedMeterEnabled;
const contextMetric = useMessageMetric(message.id as string | undefined);
const resolvedMetric = metric ?? contextMetric;
if (!enabled || isLoading || message.type !== "ai") {
if (message.type !== "ai") {
return null;
}
// Show speed-only line while streaming (no usage yet) if the user opted in,
// otherwise wait until usage arrives so the bar doesn't render on its own.
const usage = getUsageMetadata(message);
const showUsage = enabled && !isLoading && Boolean(usage);
const showSpeed = speedEnabled && resolvedMetric !== undefined && resolvedMetric.durationMs > 0;
if (!showUsage && !showSpeed) {
return null;
}
return (
<div
@@ -31,12 +101,12 @@ export function MessageTokenUsage({
className,
)}
>
<span className="inline-flex items-center gap-1 font-medium">
<CoinsIcon className="size-3" />
{t.tokenUsage.label}
</span>
{usage ? (
{showUsage && usage ? (
<>
<span className="inline-flex items-center gap-1 font-medium">
<CoinsIcon className="size-3" />
{t.tokenUsage.label}
</span>
<span>
{t.tokenUsage.input}: {formatTokenCount(usage.inputTokens)}
</span>
@@ -47,9 +117,17 @@ export function MessageTokenUsage({
{t.tokenUsage.total}: {formatTokenCount(usage.totalTokens)}
</span>
</>
) : (
<span>{t.tokenUsage.unavailableShort}</span>
)}
) : null}
{showSpeed && resolvedMetric ? (
<>
<SpeedPill metric={resolvedMetric} />
{resolvedMetric.durationMs > 0 && (
<span className="font-mono tabular-nums">
{formatDuration(resolvedMetric.durationMs)}
</span>
)}
</>
) : null}
</div>
);
}
@@ -65,12 +143,14 @@ export function MessageTokenUsageList({
isLoading?: boolean;
messages: Message[];
}) {
if (!enabled || isLoading) {
const [settings] = useLocalSettings();
const speedEnabled = settings.display.streamSpeedMeterEnabled;
if (!enabled && !speedEnabled) {
return null;
}
const aiMessages = messages.filter((message) => message.type === "ai");
if (aiMessages.length === 0) {
return null;
}
@@ -89,3 +169,25 @@ export function MessageTokenUsageList({
</>
);
}
/**
* Hoist the metrics map computation to the top of the message list so
* descendant {@link MessageTokenUsage} components can read it via context
* instead of each running its own hook.
*/
export function MessageMetricsProvider({
messages,
isLoading,
children,
}: {
messages: Message[];
isLoading: boolean;
children: React.ReactNode;
}) {
const metrics = useMessageMetrics(messages, isLoading);
return (
<MessageMetricsContext.Provider value={metrics}>
{children}
</MessageMetricsContext.Provider>
);
}
@@ -0,0 +1,235 @@
"use client";
import type { Message } from "@langchain/langgraph-sdk";
import { createContext, useContext, useEffect, useRef, useState } from "react";
import { extractReasoningContentFromMessage } from "@/core/messages/utils";
import { getUsageMetadata } from "@/core/messages/usage";
import { textOfMessage } from "@/core/threads/utils";
/**
* Two characters ≈ one token. Same heuristic we use elsewhere; gives a stable
* speed indicator for mixed Chinese/English replies without dragging in a
* real tokeniser.
*/
const CHARS_PER_TOKEN = 2;
/** Sample throttle while a message is streaming. */
const SAMPLE_THROTTLE_MS = 60;
/**
* Per-AI-message metric: real-time during streaming, frozen once the run ends.
*/
export interface MessageMetric {
/** Rolling tokens/sec, or 0 when we cannot estimate yet. */
tokensPerSec: number;
/**
* Wall-clock duration from when the message first appeared with text to
* either *now* (active) or to when the stream ended (frozen).
*/
durationMs: number;
/** True while this is the currently streaming message. */
isActive: boolean;
/**
* Whether the speed value was computed from real usage_metadata output
* tokens (``true``) vs. a char-count fallback (``false``). UI uses this
* only to add a "~" prefix when the value is approximate.
*/
fromUsage: boolean;
}
type Sample = { at: number; chars: number };
type TimingEntry = {
firstSeenAt: number;
endedAt: number | null;
samples: Sample[];
lastChars: number;
/** True iff we *have already* observed `usage_metadata.output_tokens`. */
hadUsage: boolean;
};
function isAiMessage(message: Message): boolean {
return message.type === "ai";
}
function getMessageId(message: Message, fallbackIndex: number): string {
const id = (message.id as string | undefined) ?? undefined;
return id ?? `idx-${fallbackIndex}`;
}
function messageText(message: Message): string {
// Count both the visible answer text *and* the reasoning trace so the speed
// indicator stays meaningful during the thinking phase — for reasoning
// models the answer body can be empty for tens of seconds while the model
// streams reasoning tokens, and ignoring those would make the meter sit at
// zero. We treat the two as one continuous stream of generated characters.
const body = textOfMessage(message);
const reasoning = extractReasoningContentFromMessage(message) ?? "";
const bodyText = typeof body === "string" ? body : "";
return bodyText + reasoning;
}
/**
* Returns a Map keyed by AI message id → live metric snapshot.
*
* Recording strategy:
* - Whenever a message renders with text, we anchor `firstSeenAt`.
* - On every text-length change we push a sample and recompute the rate.
* - When `isLoading` flips to false we mark *all* still-active messages as
* ended so subsequent renders return a frozen duration.
* - Token totals come from usage_metadata when present (most accurate);
* otherwise we fall back to char-count / 2.
*/
export function useMessageMetrics(
messages: Message[],
isLoading: boolean,
): Map<string, MessageMetric> {
const timingRef = useRef<Map<string, TimingEntry>>(new Map());
const lastSampleAtRef = useRef(0);
// Bump a counter to force recomputation when samples push but message refs
// didn't change — needed because the timing map mutation alone wouldn't
// re-render anything.
const [, setTick] = useState(0);
const lastActiveIdRef = useRef<string | null>(null);
// Walk messages and update timings. We intentionally read/write the ref
// synchronously inside an effect so React isn't tracking the map identity.
useEffect(() => {
const now = performance.now();
let activeId: string | null = null;
for (let i = 0; i < messages.length; i++) {
const message = messages[i];
if (!message || !isAiMessage(message)) continue;
const id = getMessageId(message, i);
const text = messageText(message);
if (!text) continue;
let entry = timingRef.current.get(id);
if (!entry) {
entry = {
firstSeenAt: now,
endedAt: null,
samples: [{ at: now, chars: text.length }],
lastChars: text.length,
hadUsage: false,
};
timingRef.current.set(id, entry);
}
// Track the most recent AI message as the active one (while streaming).
activeId = id;
// Sample only when actively streaming OR when content actually grew —
// throttle to keep arrays bounded.
const grew = text.length !== entry.lastChars;
if (grew && now - lastSampleAtRef.current >= SAMPLE_THROTTLE_MS) {
entry.samples.push({ at: now, chars: text.length });
entry.lastChars = text.length;
if (entry.samples.length > 256) {
entry.samples.splice(0, entry.samples.length - 256);
}
lastSampleAtRef.current = now;
} else if (grew) {
// Still update lastChars so the next render captures it eventually.
entry.lastChars = text.length;
}
if (getUsageMetadata(message)) {
entry.hadUsage = true;
}
}
// When streaming stops, freeze any non-ended entry to the current time.
if (!isLoading) {
for (const entry of timingRef.current.values()) {
if (entry.endedAt === null) {
entry.endedAt = now;
}
}
}
lastActiveIdRef.current = activeId;
setTick((v) => v + 1);
}, [messages, isLoading]);
// Drive a slow tick while streaming so live durations refresh smoothly.
useEffect(() => {
if (!isLoading) return;
const interval = window.setInterval(() => setTick((v) => v + 1), 300);
return () => window.clearInterval(interval);
}, [isLoading]);
// Build the snapshot map. We compute on every render so consumers see the
// freshest duration; the underlying timing storage is the source of truth.
const out = new Map<string, MessageMetric>();
const now = performance.now();
for (let i = 0; i < messages.length; i++) {
const message = messages[i];
if (!message || !isAiMessage(message)) continue;
const id = getMessageId(message, i);
const entry = timingRef.current.get(id);
if (!entry) continue;
const endedAt = entry.endedAt ?? now;
const durationMs = Math.max(0, endedAt - entry.firstSeenAt);
const isActive = isLoading && id === lastActiveIdRef.current;
let tokensPerSec = 0;
let fromUsage = false;
const usage = getUsageMetadata(message);
if (usage && usage.outputTokens > 0 && durationMs > 0) {
tokensPerSec = (usage.outputTokens / durationMs) * 1000;
fromUsage = true;
} else {
// Char-count fallback. Uses the rolling window during streaming so a
// mid-stream snapshot reflects current speed rather than average since
// the start.
if (isActive && entry.samples.length >= 2) {
const samples = entry.samples;
let start = samples.findIndex((s) => s.at >= now - 1500);
if (start === -1) start = samples.length - 2;
if (start < 0) start = 0;
const head = samples[start];
const tail = samples[samples.length - 1];
if (head && tail) {
const elapsedMs = tail.at - head.at;
const chars = Math.max(0, tail.chars - head.chars);
if (elapsedMs > 0) {
tokensPerSec = (chars / elapsedMs) * 1000 / CHARS_PER_TOKEN;
}
}
} else if (durationMs > 0) {
// Finished message without usage data: average over full duration.
const chars = entry.lastChars;
tokensPerSec = (chars / durationMs) * 1000 / CHARS_PER_TOKEN;
}
}
out.set(id, {
tokensPerSec,
durationMs,
isActive,
fromUsage,
});
}
return out;
}
const EMPTY_METRICS: Map<string, MessageMetric> = new Map();
/**
* Context for sharing the metrics map computed by ``useMessageMetrics`` from
* MessageList down to per-message renderers like MessageListItem, without
* threading a prop through every grouping branch.
*/
export const MessageMetricsContext = createContext<Map<string, MessageMetric>>(
EMPTY_METRICS,
);
export function useMessageMetric(messageId: string | undefined): MessageMetric | undefined {
const metrics = useContext(MessageMetricsContext);
return messageId ? metrics.get(messageId) : undefined;
}
@@ -0,0 +1,126 @@
"use client";
import { useMemo } from "react";
import { CopyIcon, LogOutIcon, UserIcon } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { clearStoredAuth, getStoredAuth } from "@/core/auth";
import { SettingsSection } from "./settings-section";
function roleLabel(role?: string) {
if (role === "admin") return "管理员";
if (role === "user") return "普通用户";
return role || "未知角色";
}
function copy(text: string, hint = "已复制") {
if (!text) return;
navigator.clipboard
.writeText(text)
.then(() => toast.success(hint))
.catch(() => toast.error("复制失败"));
}
export function AccountSettingsPage() {
const auth = useMemo(() => getStoredAuth(), []);
if (!auth) {
return (
<SettingsSection title="个人信息" description="当前未登录或登录信息无法读取。">
<div className="text-muted-foreground rounded-md border p-6 text-center text-sm">
没有可显示的账号信息。
</div>
</SettingsSection>
);
}
const usernameFromEmail = (auth.email ?? "").split("@")[0] ?? "";
const displayName = usernameFromEmail || auth.user_id;
const handleLogout = () => {
clearStoredAuth();
// Match the rest of the app's logout flow: blow away the cookie session
// by hitting the gateway logout endpoint and then forcing a full reload
// so any cached query state goes too.
void fetch("/api/v1/auth/logout", { method: "POST", credentials: "include" })
.catch(() => undefined)
.finally(() => {
window.location.assign("/");
});
};
return (
<SettingsSection title="个人信息" description="当前登录账号的基本信息。">
<div className="flex flex-col gap-4 rounded-xl border bg-white p-5">
<div className="flex items-center gap-3">
<div className="bg-primary/10 text-primary flex size-10 items-center justify-center rounded-full">
<UserIcon className="size-5" />
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{displayName}</div>
<div className="text-muted-foreground truncate text-xs">
{roleLabel(auth.system_role)}
</div>
</div>
<Badge variant={auth.system_role === "admin" ? "default" : "secondary"}>
{roleLabel(auth.system_role)}
</Badge>
</div>
<InfoRow
label="用户名"
value={displayName}
onCopy={() => copy(displayName, "用户名已复制")}
/>
<InfoRow
label="用户 ID"
value={auth.user_id}
onCopy={() => copy(auth.user_id, "用户 ID 已复制")}
/>
<InfoRow label="账号角色" value={roleLabel(auth.system_role)} />
<InfoRow
label="账号状态"
value={auth.needs_setup ? "待初始化" : "已就绪"}
/>
<div className="flex justify-end pt-2">
<Button variant="outline" size="sm" onClick={handleLogout}>
<LogOutIcon className="size-4" />
退出登录
</Button>
</div>
</div>
</SettingsSection>
);
}
function InfoRow({
label,
value,
onCopy,
}: {
label: string;
value: string;
onCopy?: () => void;
}) {
return (
<div className="flex items-center gap-3 border-t pt-3 first:border-t-0 first:pt-0">
<div className="text-muted-foreground w-20 shrink-0 text-xs">{label}</div>
<div className="min-w-0 flex-1 truncate text-sm">{value || "—"}</div>
{onCopy && value && (
<Button
size="icon"
variant="ghost"
className="size-7"
onClick={onCopy}
aria-label={`复制${label}`}
>
<CopyIcon className="size-3.5" />
</Button>
)}
</div>
);
}
@@ -12,8 +12,10 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { enUS, isLocale, zhCN, type Locale } from "@/core/i18n";
import { useI18n } from "@/core/i18n/hooks";
import { useLocalSettings } from "@/core/settings";
import { cn } from "@/lib/utils";
import { SettingsSection } from "./settings-section";
@@ -27,6 +29,7 @@ export function AppearanceSettingsPage() {
const { t, locale, changeLocale } = useI18n();
const { theme, setTheme, systemTheme } = useTheme();
const currentTheme = (theme ?? "system") as "system" | "light" | "dark";
const [settings, setSettings] = useLocalSettings();
const themeOptions = useMemo(
() => [
@@ -107,6 +110,30 @@ export function AppearanceSettingsPage() {
</SelectContent>
</Select>
</SettingsSection>
<Separator />
<SettingsSection
title="对话体验"
description="问答过程中的辅助显示。"
>
<div className="flex items-start justify-between gap-4 rounded-lg border p-4">
<div>
<div className="text-sm font-medium">回答速度指示器</div>
<p className="text-muted-foreground mt-1 max-w-md text-xs leading-relaxed">
在生成回答时显示一个小胶囊,实时估算当前 token/s
并用绿到红的渐变条直观反映快慢。默认关闭。
</p>
</div>
<Switch
checked={settings.display.streamSpeedMeterEnabled}
onCheckedChange={(checked) =>
setSettings("display", { streamSpeedMeterEnabled: checked })
}
aria-label="回答速度指示器"
/>
</div>
</SettingsSection>
</div>
);
}
@@ -0,0 +1,362 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { useUpsertMCPServer } from "@/core/mcp/hooks";
import type {
MCPServerConfig,
MCPServerTransport,
} from "@/core/mcp/types";
type EditMode = "create" | "edit";
export interface MCPServerEditDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
mode: EditMode;
originalName?: string;
initialServer?: MCPServerConfig;
existingNames: string[];
}
const TRANSPORT_OPTIONS: { value: MCPServerTransport; label: string }[] = [
{ value: "stdio", label: "stdio(本地进程)" },
{ value: "http", label: "http(远端 HTTP)" },
{ value: "sse", label: "sse(远端 SSE)" },
];
const DEFAULT_SERVER: MCPServerConfig = {
enabled: true,
type: "stdio",
command: "",
args: [],
env: {},
url: "",
headers: {},
description: "",
disabled_tools: [],
};
function argsToText(args?: string[]): string {
return (args ?? []).join("\n");
}
function textToArgs(text: string): string[] {
return text
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
}
function kvToText(map?: Record<string, string>): string {
if (!map) return "";
return Object.entries(map)
.map(([k, v]) => `${k}=${v}`)
.join("\n");
}
function textToKV(text: string): { value: Record<string, string>; error?: string } {
const result: Record<string, string> = {};
const lines = text.split("\n");
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line) continue;
const eq = line.indexOf("=");
if (eq <= 0) {
return { value: {}, error: `每行需为 KEY=VALUE 格式:${line}` };
}
const key = line.slice(0, eq).trim();
const value = line.slice(eq + 1);
if (!key) {
return { value: {}, error: `键名不能为空:${line}` };
}
result[key] = value;
}
return { value: result };
}
export function MCPServerEditDialog({
open,
onOpenChange,
mode,
originalName,
initialServer,
existingNames,
}: MCPServerEditDialogProps) {
const upsert = useUpsertMCPServer();
const [name, setName] = useState("");
const [type, setType] = useState<MCPServerTransport>("stdio");
const [enabled, setEnabled] = useState(true);
const [description, setDescription] = useState("");
const [command, setCommand] = useState("");
const [argsText, setArgsText] = useState("");
const [envText, setEnvText] = useState("");
const [url, setUrl] = useState("");
const [headersText, setHeadersText] = useState("");
const [disabledToolsText, setDisabledToolsText] = useState("");
useEffect(() => {
if (!open) return;
const base = initialServer ?? DEFAULT_SERVER;
setName(originalName ?? "");
setType((base.type ?? "stdio") as MCPServerTransport);
setEnabled(base.enabled);
setDescription(base.description ?? "");
setCommand(base.command ?? "");
setArgsText(argsToText(base.args));
setEnvText(kvToText(base.env));
setUrl(base.url ?? "");
setHeadersText(kvToText(base.headers));
setDisabledToolsText((base.disabled_tools ?? []).join("\n"));
}, [open, initialServer, originalName]);
const nameConflict = useMemo(() => {
const trimmed = name.trim();
if (!trimmed) return false;
if (mode === "edit" && trimmed === originalName) return false;
return existingNames.includes(trimmed);
}, [name, existingNames, mode, originalName]);
const handleSubmit = async () => {
const trimmedName = name.trim();
if (!trimmedName) {
toast.error("请填写 MCP 服务器名称");
return;
}
if (nameConflict) {
toast.error(`已存在同名 MCP 服务器:${trimmedName}`);
return;
}
const env = textToKV(envText);
if (env.error) {
toast.error(`环境变量格式错误:${env.error}`);
return;
}
const headers = textToKV(headersText);
if (headers.error) {
toast.error(`Headers 格式错误:${headers.error}`);
return;
}
const args = textToArgs(argsText);
const disabledTools = textToArgs(disabledToolsText);
if (type === "stdio" && !command.trim()) {
toast.error("stdio 传输需要填写启动命令");
return;
}
if ((type === "http" || type === "sse") && !url.trim()) {
toast.error(`${type} 传输需要填写服务器 URL`);
return;
}
const server: MCPServerConfig = {
enabled,
type,
description: description.trim(),
command: type === "stdio" ? command.trim() : null,
args: type === "stdio" ? args : [],
env: type === "stdio" ? env.value : {},
url: type === "stdio" ? null : url.trim(),
headers: type === "stdio" ? {} : headers.value,
disabled_tools: disabledTools,
};
try {
await upsert.mutateAsync({
serverName: trimmedName,
server,
originalName: mode === "edit" ? originalName : undefined,
});
toast.success(mode === "create" ? "MCP 服务器已创建" : "MCP 服务器已更新");
onOpenChange(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="max-h-[85vh] overflow-y-auto sm:max-w-2xl"
aria-describedby={undefined}
>
<DialogHeader>
<DialogTitle>
{mode === "create" ? "新增 MCP 服务器" : `编辑 MCP 服务器`}
</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 py-2">
<Field label="名称" hint="作为 MCP 服务器的唯一标识,用于工具命名前缀">
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="例如 github、filesystem"
aria-invalid={nameConflict || undefined}
/>
{nameConflict && (
<div className="text-destructive text-xs">已存在同名服务器</div>
)}
</Field>
<div className="grid grid-cols-2 gap-4">
<Field label="传输类型">
<Select
value={type}
onValueChange={(v) => setType(v as MCPServerTransport)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{TRANSPORT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field label="启用">
<div className="flex h-9 items-center">
<Switch
checked={enabled}
onCheckedChange={setEnabled}
/>
<span className="text-muted-foreground ml-2 text-xs">
{enabled ? "已启用" : "已停用"}
</span>
</div>
</Field>
</div>
<Field label="描述">
<Textarea
rows={2}
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="给这个 MCP 服务器写一段说明,会展示在工具列表里"
/>
</Field>
{type === "stdio" && (
<>
<Field
label="启动命令"
hint="例如 npx、uvx、python,配合参数启动 MCP server 进程"
>
<Input
value={command}
onChange={(e) => setCommand(e.target.value)}
placeholder="npx"
/>
</Field>
<Field label="参数" hint="每行一个参数">
<Textarea
rows={3}
value={argsText}
onChange={(e) => setArgsText(e.target.value)}
placeholder={"-y\n@modelcontextprotocol/server-github"}
/>
</Field>
<Field
label="环境变量"
hint="每行 KEY=VALUE;值以 $ 开头会在加载时解析为对应环境变量"
>
<Textarea
rows={3}
value={envText}
onChange={(e) => setEnvText(e.target.value)}
placeholder={"GITHUB_TOKEN=$GITHUB_TOKEN"}
/>
</Field>
</>
)}
{(type === "http" || type === "sse") && (
<>
<Field label="服务器 URL">
<Input
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://example.com/mcp"
/>
</Field>
<Field label="Headers" hint="每行 KEY=VALUE">
<Textarea
rows={3}
value={headersText}
onChange={(e) => setHeadersText(e.target.value)}
placeholder={"Authorization=Bearer xxx"}
/>
</Field>
</>
)}
<Field
label="禁用的工具"
hint="每行一个工具名(不带服务器前缀),加载时会被过滤掉"
>
<Textarea
rows={2}
value={disabledToolsText}
onChange={(e) => setDisabledToolsText(e.target.value)}
placeholder={"some_dangerous_tool"}
/>
</Field>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button onClick={handleSubmit} disabled={upsert.isPending}>
{upsert.isPending
? "保存中..."
: mode === "create"
? "创建"
: "保存"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function Field({
label,
hint,
children,
}: {
label: string;
hint?: string;
children: React.ReactNode;
}) {
return (
<div className="flex flex-col gap-1">
<div className="text-sm font-medium">{label}</div>
{hint && <div className="text-muted-foreground text-xs">{hint}</div>}
{children}
</div>
);
}
@@ -6,6 +6,7 @@ import {
BrainIcon,
PaletteIcon,
SparklesIcon,
UserIcon,
WrenchIcon,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
@@ -18,6 +19,7 @@ import {
} from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { AboutSettingsPage } from "@/components/workspace/settings/about-settings-page";
import { AccountSettingsPage } from "@/components/workspace/settings/account-settings-page";
import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page";
import { MemorySettingsPage } from "@/components/workspace/settings/memory-settings-page";
import { NotificationSettingsPage } from "@/components/workspace/settings/notification-settings-page";
@@ -27,6 +29,7 @@ import { useI18n } from "@/core/i18n/hooks";
import { cn } from "@/lib/utils";
type SettingsSection =
| "account"
| "appearance"
| "memory"
| "tools"
@@ -71,6 +74,11 @@ export function SettingsDialog(props: SettingsDialogProps) {
},
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
{ id: "skills", label: t.settings.sections.skills, icon: SparklesIcon },
{
id: "account",
label: "个人信息",
icon: UserIcon,
},
// { id: "about", label: t.settings.sections.about, icon: InfoIcon },
],
[
@@ -124,6 +132,7 @@ export function SettingsDialog(props: SettingsDialogProps) {
</nav>
<ScrollArea className="h-full min-h-0 min-w-0 rounded-lg border">
<div className="min-w-0 space-y-8 p-6">
{activeSection === "account" && <AccountSettingsPage />}
{activeSection === "appearance" && <AppearanceSettingsPage />}
{activeSection === "memory" && <MemorySettingsPage />}
{activeSection === "tools" && <ToolSettingsPage />}
@@ -1,5 +1,18 @@
"use client";
import { Pencil, Plus, Trash2 } 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 {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Item,
ItemActions,
@@ -8,16 +21,54 @@ import {
ItemTitle,
} from "@/components/ui/item";
import { Switch } from "@/components/ui/switch";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import { useMCPConfig, useEnableMCPServer } from "@/core/mcp/hooks";
import {
useDeleteMCPServer,
useEnableMCPServer,
useMCPConfig,
} from "@/core/mcp/hooks";
import type { MCPServerConfig } from "@/core/mcp/types";
import { env } from "@/env";
import { MCPServerEditDialog } from "./mcp-server-edit-dialog";
import { SettingsSection } from "./settings-section";
export function ToolSettingsPage() {
const { t } = useI18n();
const { config, isLoading, error } = useMCPConfig();
const isAdmin = getStoredAuth()?.system_role === "admin";
const isStaticMode = env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true";
const [editOpen, setEditOpen] = useState(false);
const [editMode, setEditMode] = useState<"create" | "edit">("create");
const [editingName, setEditingName] = useState<string | undefined>(undefined);
const [editingServer, setEditingServer] = useState<
MCPServerConfig | undefined
>(undefined);
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const servers = useMemo(() => {
if (!config) return [] as Array<[string, MCPServerConfig]>;
return Object.entries(config.mcp_servers);
}, [config]);
const existingNames = useMemo(() => servers.map(([n]) => n), [servers]);
const handleCreate = () => {
setEditMode("create");
setEditingName(undefined);
setEditingServer(undefined);
setEditOpen(true);
};
const handleEdit = (name: string, server: MCPServerConfig) => {
setEditMode("edit");
setEditingName(name);
setEditingServer(server);
setEditOpen(true);
};
return (
<SettingsSection
title={t.settings.tools.title}
@@ -26,38 +77,128 @@ export function ToolSettingsPage() {
{isLoading ? (
<div className="text-muted-foreground text-sm">{t.common.loading}</div>
) : error ? (
<div>Error: {error.message}</div>
<div className="text-destructive text-sm">Error: {error.message}</div>
) : (
config && <MCPServerList servers={config.mcp_servers} />
<div className="flex w-full flex-col gap-3">
{isAdmin && (
<div className="flex justify-end">
<Button
size="sm"
onClick={handleCreate}
disabled={isStaticMode}
>
<Plus className="size-4" />
新增 MCP 服务器
</Button>
</div>
)}
<MCPServerList
servers={servers}
isAdmin={isAdmin}
isStaticMode={isStaticMode}
onEdit={handleEdit}
onDelete={(name) => setDeleteTarget(name)}
/>
</div>
)}
<MCPServerEditDialog
open={editOpen}
onOpenChange={setEditOpen}
mode={editMode}
originalName={editingName}
initialServer={editingServer}
existingNames={existingNames}
/>
<DeleteConfirmDialog
target={deleteTarget}
onOpenChange={(open) => !open && setDeleteTarget(null)}
/>
</SettingsSection>
);
}
function MCPServerList({
servers,
isAdmin,
isStaticMode,
onEdit,
onDelete,
}: {
servers: Record<string, MCPServerConfig>;
servers: Array<[string, MCPServerConfig]>;
isAdmin: boolean;
isStaticMode: boolean;
onEdit: (name: string, server: MCPServerConfig) => void;
onDelete: (name: string) => void;
}) {
const { mutate: enableMCPServer } = useEnableMCPServer();
if (servers.length === 0) {
return (
<div className="text-muted-foreground rounded-md border p-6 text-center text-sm">
{isAdmin
? "还没有配置任何 MCP 服务器,点上面的按钮添加一个。"
: "管理员还没有配置任何 MCP 服务器。"}
</div>
);
}
return (
<div className="flex w-full flex-col gap-4">
{Object.entries(servers).map(([name, config]) => (
{servers.map(([name, config]) => (
<Item className="w-full" variant="outline" key={name}>
<ItemContent>
<ItemTitle>
<div className="flex items-center gap-2">
<div>{name}</div>
<div className="flex flex-wrap items-center gap-2">
<span>{name}</span>
<Badge variant="outline" className="text-xs">
{config.type ?? "stdio"}
</Badge>
{config.disabled_tools &&
config.disabled_tools.length > 0 && (
<Badge variant="secondary" className="text-xs">
已禁用 {config.disabled_tools.length} 个工具
</Badge>
)}
</div>
</ItemTitle>
<ItemDescription className="line-clamp-4">
{config.description}
{config.description ||
(config.type === "stdio"
? config.command
: config.url) ||
"暂无描述"}
</ItemDescription>
</ItemContent>
<ItemActions>
<ItemActions className="flex items-center gap-2">
{isAdmin && (
<>
<Button
size="icon"
variant="ghost"
className="size-8"
onClick={() => onEdit(name, config)}
disabled={isStaticMode}
aria-label="编辑"
>
<Pencil className="size-4" />
</Button>
<Button
size="icon"
variant="ghost"
className="text-destructive hover:text-destructive size-8"
onClick={() => onDelete(name)}
disabled={isStaticMode}
aria-label="删除"
>
<Trash2 className="size-4" />
</Button>
</>
)}
<Switch
checked={config.enabled}
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
disabled={isStaticMode || !isAdmin}
onCheckedChange={(checked) =>
enableMCPServer({ serverName: name, enabled: checked })
}
@@ -68,3 +209,49 @@ function MCPServerList({
</div>
);
}
function DeleteConfirmDialog({
target,
onOpenChange,
}: {
target: string | null;
onOpenChange: (open: boolean) => void;
}) {
const deleteMcp = useDeleteMCPServer();
const handleConfirm = async () => {
if (!target) return;
try {
await deleteMcp.mutateAsync({ serverName: target });
toast.success(`已删除 MCP 服务器:${target}`);
onOpenChange(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
};
return (
<Dialog open={target !== null} onOpenChange={onOpenChange}>
<DialogContent aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>删除 MCP 服务器</DialogTitle>
</DialogHeader>
<div className="py-2 text-sm">
确定要删除 MCP 服务器 <b>{target}</b> 吗?该操作不可撤销。
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button
variant="destructive"
onClick={handleConfirm}
disabled={deleteMcp.isPending}
>
{deleteMcp.isPending ? "删除中..." : "删除"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -1,6 +1,13 @@
"use client";
import { ChevronsUpDown, MessageSquarePlusIcon, Settings2Icon, SettingsIcon } from "lucide-react";
import {
ChevronsUpDown,
GaugeIcon,
MessageSquareCodeIcon,
MessageSquarePlusIcon,
Settings2Icon,
SettingsIcon,
} from "lucide-react";
import { useEffect, useState } from "react";
import {
@@ -103,6 +110,26 @@ export function WorkspaceNavMenu() {
推荐问题配置
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() =>
navigate("/page/workspace/settings/prompt-prefix")
}
>
<MessageSquareCodeIcon />
问答前缀配置
</DropdownMenuItem>
)}
{isAdmin && (
<DropdownMenuItem
onClick={() =>
navigate("/page/workspace/settings/llm-metrics")
}
>
<GaugeIcon />
大模型调用统计
</DropdownMenuItem>
)}
</DropdownMenuGroup>
<DropdownMenuSeparator />
</DropdownMenuContent>
+98
View File
@@ -0,0 +1,98 @@
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
export interface LlmMetric {
id: string;
created_at?: string | null;
user_id?: string | null;
user_name?: string | null;
thread_id?: string | null;
run_id?: string | null;
agent_name?: string | null;
model_name?: string | null;
duration_ms: number;
input_tokens?: number | null;
output_tokens?: number | null;
total_tokens?: number | null;
tokens_per_sec?: number | null;
status: "success" | "error" | string;
error_type?: string | null;
error_message?: string | null;
}
export interface LlmMetricsListResponse {
items: LlmMetric[];
total: number;
limit: number;
offset: number;
}
export interface LlmMetricsFilters {
user_id?: string;
user_name?: string;
model_name?: string;
status?: "success" | "error";
since?: string;
until?: string;
}
export interface LlmMetricsListQuery extends LlmMetricsFilters {
limit?: number;
offset?: number;
}
function buildQuery(filters: LlmMetricsListQuery): string {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(filters)) {
if (value === undefined || value === null || value === "") continue;
params.set(key, String(value));
}
const qs = params.toString();
return qs ? `?${qs}` : "";
}
export async function loadLlmMetrics(
query: LlmMetricsListQuery,
): Promise<LlmMetricsListResponse> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/admin/llm-metrics${buildQuery(query)}`,
);
if (!response.ok) {
const detail = await response.json().catch(() => ({ detail: response.statusText }));
throw new Error(detail.detail ?? "Failed to load LLM metrics");
}
return (await response.json()) as LlmMetricsListResponse;
}
/**
* Trigger the Excel download. Returns the blob URL used so callers can revoke
* it if they want; the function itself handles the click and cleanup.
*/
export async function downloadLlmMetricsExcel(
filters: LlmMetricsFilters,
): Promise<void> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/admin/llm-metrics/export${buildQuery(filters)}`,
);
if (!response.ok) {
const detail = await response.json().catch(() => ({ detail: response.statusText }));
throw new Error(detail.detail ?? "Failed to export LLM metrics");
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const filename = (() => {
const header = response.headers.get("content-disposition") ?? "";
const utf8 = /filename\*=UTF-8''([^;]+)/i.exec(header);
if (utf8?.[1]) return decodeURIComponent(utf8[1]);
const plain = /filename="?([^";]+)"?/i.exec(header);
return plain?.[1] ?? `llm_call_metrics.xlsx`;
})();
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.rel = "noopener";
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 30_000);
}
@@ -0,0 +1,12 @@
import { useQuery } from "@tanstack/react-query";
import { loadLlmMetrics, type LlmMetricsListQuery } from "./api";
export function useLlmMetrics(query: LlmMetricsListQuery) {
return useQuery({
queryKey: ["llmMetrics", query],
queryFn: () => loadLlmMetrics(query),
refetchOnWindowFocus: false,
placeholderData: (previous) => previous,
});
}
+42
View File
@@ -1,6 +1,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { loadMCPConfig, updateMCPConfig } from "./api";
import type { MCPServerConfig } from "./types";
export function useMCPConfig() {
const { data, isLoading, error } = useQuery({
@@ -42,3 +43,44 @@ export function useEnableMCPServer() {
},
});
}
export function useUpsertMCPServer() {
const queryClient = useQueryClient();
const { config } = useMCPConfig();
return useMutation({
mutationFn: async ({
serverName,
server,
originalName,
}: {
serverName: string;
server: MCPServerConfig;
originalName?: string;
}) => {
const next = { ...(config?.mcp_servers ?? {}) };
if (originalName && originalName !== serverName) {
delete next[originalName];
}
next[serverName] = server;
await updateMCPConfig({ mcp_servers: next });
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["mcpConfig"] });
},
});
}
export function useDeleteMCPServer() {
const queryClient = useQueryClient();
const { config } = useMCPConfig();
return useMutation({
mutationFn: async ({ serverName }: { serverName: string }) => {
const next = { ...(config?.mcp_servers ?? {}) };
delete next[serverName];
await updateMCPConfig({ mcp_servers: next });
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["mcpConfig"] });
},
});
}
+28 -1
View File
@@ -1,6 +1,33 @@
export interface MCPServerConfig extends Record<string, unknown> {
export type MCPServerTransport = "stdio" | "http" | "sse";
export interface MCPOAuthConfig {
enabled?: boolean;
token_url?: string;
grant_type?: "client_credentials" | "refresh_token";
client_id?: string | null;
client_secret?: string | null;
refresh_token?: string | null;
scope?: string | null;
audience?: string | null;
token_field?: string;
token_type_field?: string;
expires_in_field?: string;
default_token_type?: string;
refresh_skew_seconds?: number;
extra_token_params?: Record<string, string>;
}
export interface MCPServerConfig {
enabled: boolean;
type?: MCPServerTransport;
command?: string | null;
args?: string[];
env?: Record<string, string>;
url?: string | null;
headers?: Record<string, string>;
oauth?: MCPOAuthConfig | null;
description: string;
disabled_tools?: string[];
}
export interface MCPConfig {
@@ -1,104 +1,181 @@
/**
* Convert markdown text to a Word (.docx) Blob in the browser.
*
* Implementation notes:
* - Uses the `docx` npm package to construct an OOXML document client-side.
* - Parser is intentionally lightweight: it covers the markdown features that
* scheduled-task agent reports actually emit — ATX headings, paragraphs,
* ordered/unordered lists, fenced code blocks, blockquotes, horizontal
* rules, and inline `**bold**` / `*italic*` / `` `code` `` runs.
* - We deliberately do NOT reach for a full markdown AST library (remark/
* unified) to keep the bundle small. Edge cases (tables, footnotes, html)
* degrade to plain text rather than failing loudly.
* Designed for scheduled-task reports that often contain Chinese text,
* markdown tables, and emojis. Compared to Word's stock heading styles we
* deliberately:
* - Force every run color to pure black (`#000000`) so headings, links,
* and inline code never render as Word's default blue / hyperlink theme.
* - Render markdown tables as real OOXML tables (full-width, auto-wrapping)
* instead of falling back to plain text rows that overflow the page.
* - Strip variation selectors, zero-width joiners, and other invisible
* characters that often break Word renderers when emojis leak into the
* report.
*
* Block grammar handled line-by-line:
* - ATX heading: `^(#{1,6})\s+(.*)$`
* - Fenced code: lines between ``` ``` are emitted verbatim in a mono font
* - Blockquote: `^>\s?(.*)$`
* - Ordered list: `^\s*(\d+)\.\s+(.*)$`
* - Unordered list: `^\s*[-*+]\s+(.*)$`
* - Horizontal rule: `^(---|\*\*\*|___)\s*$`
* - Pipe table: contiguous lines starting with `|` plus a separator row
* - Blank line: paragraph break
* - Anything else: paragraph (with inline formatting)
*
* Inline grammar handled:
* `code`, **bold**, __bold__, *italic*, _italic_, [text](url)
*/
import {
AlignmentType,
BorderStyle,
Document,
HeadingLevel,
Packer,
Paragraph,
Table,
TableCell,
TableRow,
TextRun,
WidthType,
type IRunOptions,
} from "docx";
const HEADING_LEVELS = [
HeadingLevel.HEADING_1,
HeadingLevel.HEADING_2,
HeadingLevel.HEADING_3,
HeadingLevel.HEADING_4,
HeadingLevel.HEADING_5,
HeadingLevel.HEADING_6,
];
const BLACK = "000000";
const BODY_FONT = { ascii: "Calibri", eastAsia: "Microsoft YaHei" };
const MONO_FONT = { ascii: "Consolas", eastAsia: "Microsoft YaHei" };
type InlineSegment = { text: string; bold?: boolean; italic?: boolean; code?: boolean };
const HEADING_SIZES_HALF_PT: Record<number, number> = {
// Word "size" property is half-points. 32 = 16pt.
1: 36,
2: 32,
3: 28,
4: 26,
5: 24,
6: 22,
};
const BODY_SIZE_HALF_PT = 22; // 11pt
type InlineSegment = {
text: string;
bold?: boolean;
italic?: boolean;
code?: boolean;
};
/**
* Drop characters that Word renders inconsistently or that frequently smuggle
* emoji glitches into corporate reports.
*
* Removes:
* - Variation selectors (FE00–FE0F, E0100–E01EF)
* - Zero-width joiner / non-joiner / space (200C, 200D, FEFF, 200B)
* - Soft hyphen (00AD)
* - Object replacement character (FFFC)
*/
function sanitizeText(text: string): string {
return text
.replace(/[︀-️]/g, "")
.replace(/[​-‍]/g, "")
.replace(/­/g, "")
.replace(//g, "")
.replace(/[\u{E0100}-\u{E01EF}]/gu, "");
}
/**
* Tokenise a single line of markdown into formatted segments.
*
* Recognised inline syntax:
* `code`, **bold**, __bold__, *italic*, _italic_
* Order matters: code spans win over emphasis so backticks don't get parsed
* as italic delimiters.
* `code`, **bold**, __bold__, *italic*, _italic_, [text](url)
* Markdown links are flattened to their text content — we deliberately do not
* emit hyperlink runs because they pick up Word's blue theme color.
*/
function parseInlineSegments(text: string): InlineSegment[] {
const cleaned = sanitizeText(text);
// Strip markdown link syntax first so the regex below doesn't trip on `[...]`.
const withoutLinks = cleaned.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1");
const tokens: InlineSegment[] = [];
const pattern = /(`+)([^`]+)\1|\*\*([^*]+)\*\*|__([^_]+)__|\*([^*]+)\*|_([^_]+)_/g;
const pattern =
/(`+)([^`]+)\1|\*\*([^*]+)\*\*|__([^_]+)__|\*([^*]+)\*|_([^_]+)_/g;
let cursor = 0;
let match: RegExpExecArray | null;
while ((match = pattern.exec(text)) !== null) {
while ((match = pattern.exec(withoutLinks)) !== null) {
if (match.index > cursor) {
tokens.push({ text: text.slice(cursor, match.index) });
tokens.push({ text: withoutLinks.slice(cursor, match.index) });
}
if (match[2] !== undefined) {
tokens.push({ text: match[2], code: true });
} else if (match[3] !== undefined) {
tokens.push({ text: match[3], bold: true });
} else if (match[4] !== undefined) {
tokens.push({ text: match[4], bold: true });
} else if (match[5] !== undefined) {
if (match[2] !== undefined) tokens.push({ text: match[2], code: true });
else if (match[3] !== undefined) tokens.push({ text: match[3], bold: true });
else if (match[4] !== undefined) tokens.push({ text: match[4], bold: true });
else if (match[5] !== undefined)
tokens.push({ text: match[5], italic: true });
} else if (match[6] !== undefined) {
else if (match[6] !== undefined)
tokens.push({ text: match[6], italic: true });
}
cursor = pattern.lastIndex;
}
if (cursor < text.length) {
tokens.push({ text: text.slice(cursor) });
if (cursor < withoutLinks.length) {
tokens.push({ text: withoutLinks.slice(cursor) });
}
return tokens.length > 0 ? tokens : [{ text }];
return tokens.length > 0 ? tokens : [{ text: withoutLinks }];
}
function segmentsToRuns(segments: InlineSegment[], extra?: Partial<IRunOptions>): TextRun[] {
function segmentsToRuns(
segments: InlineSegment[],
extra?: Partial<IRunOptions>,
): TextRun[] {
return segments.map((seg) => {
const opts: IRunOptions = {
text: seg.text,
bold: seg.bold,
italics: seg.italic,
color: BLACK,
font: seg.code ? MONO_FONT : BODY_FONT,
size: extra?.size ?? BODY_SIZE_HALF_PT,
...extra,
};
if (seg.code) {
opts.font = { name: "Consolas" };
}
return new TextRun(opts);
});
}
function makeParagraphFromInline(text: string): Paragraph {
return new Paragraph({
spacing: { after: 120 },
children: segmentsToRuns(parseInlineSegments(text)),
});
}
function makeHeadingParagraph(level: number, text: string): Paragraph {
const clamped = Math.max(1, Math.min(level, 6));
return new Paragraph({
heading: HEADING_LEVELS[Math.min(level, HEADING_LEVELS.length) - 1],
children: segmentsToRuns(parseInlineSegments(text)),
// We attach a heading level so the table-of-contents still works, but the
// run-level color / font / size below override Word's blue theme.
heading:
({
1: HeadingLevel.HEADING_1,
2: HeadingLevel.HEADING_2,
3: HeadingLevel.HEADING_3,
4: HeadingLevel.HEADING_4,
5: HeadingLevel.HEADING_5,
6: HeadingLevel.HEADING_6,
}[clamped] as (typeof HeadingLevel)[keyof typeof HeadingLevel]) ?? undefined,
spacing: { before: 200, after: 120 },
children: segmentsToRuns(parseInlineSegments(text), {
bold: true,
color: BLACK,
size: HEADING_SIZES_HALF_PT[clamped] ?? BODY_SIZE_HALF_PT,
}),
});
}
function makeListItem(text: string, ordered: boolean, level: number): Paragraph {
function makeListItem(
text: string,
ordered: boolean,
level: number,
): Paragraph {
return new Paragraph({
spacing: { after: 80 },
children: segmentsToRuns(parseInlineSegments(text)),
numbering: ordered
? { reference: "ordered-list", level: Math.min(level, 8) }
@@ -109,10 +186,13 @@ function makeListItem(text: string, ordered: boolean, level: number): Paragraph
function makeCodeParagraph(line: string): Paragraph {
return new Paragraph({
spacing: { after: 0 },
children: [
new TextRun({
text: line || " ",
font: { name: "Consolas" },
text: sanitizeText(line) || " ",
font: MONO_FONT,
color: BLACK,
size: BODY_SIZE_HALF_PT,
}),
],
});
@@ -120,45 +200,124 @@ function makeCodeParagraph(line: string): Paragraph {
function makeQuoteParagraph(text: string): Paragraph {
return new Paragraph({
children: segmentsToRuns(parseInlineSegments(text), { italics: true }),
indent: { left: 720 },
spacing: { after: 120 },
children: segmentsToRuns(parseInlineSegments(text), { italics: true }),
});
}
function makeHorizontalRule(): Paragraph {
return new Paragraph({
alignment: AlignmentType.CENTER,
children: [new TextRun({ text: "────────" })],
spacing: { before: 120, after: 120 },
children: [
new TextRun({
text: "────────",
color: BLACK,
font: BODY_FONT,
size: BODY_SIZE_HALF_PT,
}),
],
});
}
const CELL_BORDER = {
style: BorderStyle.SINGLE,
size: 4,
color: "BFBFBF",
};
function makeTableCell(
text: string,
options: { header?: boolean } = {},
): TableCell {
const segments = parseInlineSegments(text);
return new TableCell({
margins: { top: 80, bottom: 80, left: 100, right: 100 },
children: [
new Paragraph({
spacing: { after: 0 },
children: segmentsToRuns(segments, {
bold: options.header || undefined,
size: BODY_SIZE_HALF_PT,
}),
}),
],
});
}
function splitTableRow(line: string): string[] {
// Strip leading/trailing pipe and any escaped pipes (rare in agent output).
const trimmed = line.replace(/^\s*\|/, "").replace(/\|\s*$/, "");
return trimmed.split("|").map((cell) => cell.trim());
}
function buildTable(headerLine: string, bodyLines: string[]): Table {
const headerCells = splitTableRow(headerLine);
const colCount = headerCells.length;
const rows: TableRow[] = [];
rows.push(
new TableRow({
tableHeader: true,
children: headerCells.map((cell) =>
makeTableCell(cell, { header: true }),
),
}),
);
for (const row of bodyLines) {
const cells = splitTableRow(row);
// Pad/truncate to match the header column count so Word renders evenly.
const normalized = Array.from(
{ length: colCount },
(_, i) => cells[i] ?? "",
);
rows.push(
new TableRow({
children: normalized.map((c) => makeTableCell(c)),
}),
);
}
return new Table({
width: { size: 100, type: WidthType.PERCENTAGE },
rows,
borders: {
top: CELL_BORDER,
bottom: CELL_BORDER,
left: CELL_BORDER,
right: CELL_BORDER,
insideHorizontal: CELL_BORDER,
insideVertical: CELL_BORDER,
},
});
}
function isTableSeparator(line: string): boolean {
// e.g. |---|:---:|---:| with optional spaces
return /^\s*\|?(\s*:?-{3,}:?\s*\|)+\s*:?-{3,}:?\s*\|?\s*$/.test(line);
}
function looksLikeTableRow(line: string): boolean {
return /^\s*\|.*\|\s*$/.test(line) || /^\s*\|.+/.test(line);
}
/**
* Convert markdown text into an array of docx Paragraph objects.
*
* Block grammar handled (line-by-line):
* - ATX heading: `^(#{1,6})\s+(.*)$`
* - Fenced code: lines between ``` ``` are emitted verbatim
* - Blockquote: `^>\s?(.*)$`
* - Ordered list: `^\s*(\d+)\.\s+(.*)$`
* - Unordered list: `^\s*[-*+]\s+(.*)$`
* - Horizontal rule: `^(---|\*\*\*|___)\s*$`
* - Blank line: paragraph break
* - Anything else: paragraph (with inline formatting)
* Convert markdown text into an array of docx block elements (Paragraph | Table).
*/
function markdownToParagraphs(markdown: string): Paragraph[] {
function markdownToBlocks(markdown: string): Array<Paragraph | Table> {
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
const paragraphs: Paragraph[] = [];
const blocks: Array<Paragraph | Table> = [];
let inFence = false;
let paragraphBuffer: string[] = [];
const flushParagraph = () => {
if (paragraphBuffer.length === 0) return;
paragraphs.push(makeParagraphFromInline(paragraphBuffer.join(" ")));
blocks.push(makeParagraphFromInline(paragraphBuffer.join(" ")));
paragraphBuffer = [];
};
for (const rawLine of lines) {
for (let i = 0; i < lines.length; i++) {
const rawLine = lines[i] ?? "";
const line = rawLine.replace(/\t/g, " ");
if (line.trim().startsWith("```")) {
@@ -167,7 +326,7 @@ function markdownToParagraphs(markdown: string): Paragraph[] {
continue;
}
if (inFence) {
paragraphs.push(makeCodeParagraph(line));
blocks.push(makeCodeParagraph(line));
continue;
}
@@ -179,36 +338,63 @@ function markdownToParagraphs(markdown: string): Paragraph[] {
const heading = /^(#{1,6})\s+(.*)$/.exec(line);
if (heading) {
flushParagraph();
paragraphs.push(makeHeadingParagraph(heading[1].length, heading[2].trim()));
blocks.push(
makeHeadingParagraph(heading[1]!.length, heading[2]!.trim()),
);
continue;
}
if (/^(?:---|\*\*\*|___)\s*$/.test(line)) {
flushParagraph();
paragraphs.push(makeHorizontalRule());
blocks.push(makeHorizontalRule());
continue;
}
const blockquote = /^>\s?(.*)$/.exec(line);
if (blockquote) {
flushParagraph();
paragraphs.push(makeQuoteParagraph(blockquote[1]));
blocks.push(makeQuoteParagraph(blockquote[1] ?? ""));
continue;
}
// Table detection — a header row followed by a separator row.
if (
looksLikeTableRow(line) &&
i + 1 < lines.length &&
isTableSeparator(lines[i + 1] ?? "")
) {
flushParagraph();
const headerLine = line;
i += 2; // skip header + separator
const body: string[] = [];
while (i < lines.length && looksLikeTableRow(lines[i] ?? "")) {
body.push(lines[i] ?? "");
i++;
}
// Re-step back one so the outer loop's i++ lands on the next unprocessed
// line. Without this, blank lines after the table get swallowed.
i--;
blocks.push(buildTable(headerLine, body));
// Word's table rendering eats following spacing; inject a tiny gap.
blocks.push(
new Paragraph({ spacing: { after: 0 }, children: [new TextRun("")] }),
);
continue;
}
const orderedItem = /^(\s*)(\d+)\.\s+(.*)$/.exec(line);
if (orderedItem) {
flushParagraph();
const indent = Math.floor(orderedItem[1].length / 2);
paragraphs.push(makeListItem(orderedItem[3], true, indent));
const indent = Math.floor(orderedItem[1]!.length / 2);
blocks.push(makeListItem(orderedItem[3]!, true, indent));
continue;
}
const unorderedItem = /^(\s*)[-*+]\s+(.*)$/.exec(line);
if (unorderedItem) {
flushParagraph();
const indent = Math.floor(unorderedItem[1].length / 2);
paragraphs.push(makeListItem(unorderedItem[2], false, indent));
const indent = Math.floor(unorderedItem[1]!.length / 2);
blocks.push(makeListItem(unorderedItem[2]!, false, indent));
continue;
}
@@ -216,33 +402,70 @@ function markdownToParagraphs(markdown: string): Paragraph[] {
}
flushParagraph();
return paragraphs;
return blocks;
}
/**
* Convert a markdown string into a .docx Blob suitable for download.
*/
export async function markdownToDocxBlob(markdown: string, title?: string): Promise<Blob> {
const children: Paragraph[] = [];
export async function markdownToDocxBlob(
markdown: string,
title?: string,
): Promise<Blob> {
const children: Array<Paragraph | Table> = [];
if (title?.trim()) {
children.push(
new Paragraph({
heading: HeadingLevel.TITLE,
children: [new TextRun({ text: title.trim(), bold: true })],
spacing: { before: 0, after: 240 },
children: [
new TextRun({
text: sanitizeText(title.trim()),
bold: true,
color: BLACK,
font: BODY_FONT,
size: 40, // 20pt
}),
],
}),
);
}
children.push(...markdownToParagraphs(markdown));
children.push(...markdownToBlocks(markdown));
const doc = new Document({
styles: {
default: {
document: {
run: {
color: BLACK,
font: BODY_FONT,
size: BODY_SIZE_HALF_PT,
},
},
},
},
numbering: {
config: [
{
reference: "ordered-list",
levels: [
{ level: 0, format: "decimal", text: "%1.", alignment: AlignmentType.START },
{ level: 1, format: "decimal", text: "%2.", alignment: AlignmentType.START },
{ level: 2, format: "decimal", text: "%3.", alignment: AlignmentType.START },
{
level: 0,
format: "decimal",
text: "%1.",
alignment: AlignmentType.START,
},
{
level: 1,
format: "decimal",
text: "%2.",
alignment: AlignmentType.START,
},
{
level: 2,
format: "decimal",
text: "%3.",
alignment: AlignmentType.START,
},
],
},
],
@@ -27,6 +27,8 @@ export type ScheduledTask = {
published_at?: string | null;
role?: ScheduledTaskRole;
_subscription?: ScheduledTaskSubscription | null;
creator_user_id?: string | null;
creator_email?: string | null;
};
export type PublishedScheduledTask = {
@@ -37,6 +39,8 @@ export type PublishedScheduledTask = {
timezone: string;
published_at?: string | null;
owner_user_id: string;
creator_user_id?: string | null;
creator_email?: string | null;
};
export type SchedulerThread = {
+15
View File
@@ -4,6 +4,9 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
notification: {
enabled: true,
},
display: {
streamSpeedMeterEnabled: false,
},
context: {
model_name: undefined,
mode: undefined,
@@ -22,6 +25,14 @@ export interface LocalSettings {
notification: {
enabled: boolean;
};
display: {
/**
* Whether to render a small token/s speed meter while a streaming
* AI reply is in flight. Default off; controlled by the user from
* the appearance settings page.
*/
streamSpeedMeterEnabled: boolean;
};
context: Omit<
AgentThreadContext,
| "thread_id"
@@ -48,6 +59,10 @@ function mergeLocalSettings(settings?: Partial<LocalSettings>): LocalSettings {
...DEFAULT_LOCAL_SETTINGS.notification,
...settings?.notification,
},
display: {
...DEFAULT_LOCAL_SETTINGS.display,
...settings?.display,
},
};
}
@@ -0,0 +1,44 @@
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
export interface PromptPrefixSettings {
enabled: boolean;
switch_label: string;
prompt_prefix: string;
}
export interface PromptPrefixUpdate {
enabled?: boolean;
switch_label?: string;
prompt_prefix?: string;
}
export async function loadPromptPrefixSettings(): Promise<PromptPrefixSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/prompt-prefix`,
);
if (!response.ok) {
throw new Error(`Failed to load prompt prefix settings: ${response.status}`);
}
return (await response.json()) as PromptPrefixSettings;
}
export async function updatePromptPrefixSettings(
body: PromptPrefixUpdate,
): Promise<PromptPrefixSettings> {
const response = await apiFetch(
`${getBackendBaseURL()}/api/system-settings/prompt-prefix`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
if (!response.ok) {
const detail = await response
.json()
.catch(() => ({ detail: "Failed to update prompt prefix" }));
throw new Error(detail.detail ?? "Failed to update prompt prefix");
}
return (await response.json()) as PromptPrefixSettings;
}
@@ -0,0 +1,29 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
loadPromptPrefixSettings,
updatePromptPrefixSettings,
type PromptPrefixUpdate,
} from "./api";
const PROMPT_PREFIX_KEY = ["systemSettings", "promptPrefix"] as const;
export function usePromptPrefixSettings() {
return useQuery({
queryKey: PROMPT_PREFIX_KEY,
queryFn: () => loadPromptPrefixSettings(),
staleTime: 60_000,
refetchOnWindowFocus: false,
refetchOnMount: false,
});
}
export function useUpdatePromptPrefixSettings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: PromptPrefixUpdate) => updatePromptPrefixSettings(body),
onSuccess: (data) => {
queryClient.setQueryData(PROMPT_PREFIX_KEY, data);
},
});
}
+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>
);
}
@@ -27,6 +27,7 @@ from app.gateway.routers import (
auth,
channels,
feedback,
llm_metrics,
mcp,
memory,
models,
@@ -35,6 +36,7 @@ from app.gateway.routers import (
scheduled_tasks,
skills,
suggestions,
system_settings,
thread_runs,
threads,
uploads,
@@ -495,6 +497,12 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Scheduled task API is mounted at /api/scheduled-tasks
app.include_router(scheduled_tasks.router)
# System settings API is mounted at /api/system-settings
app.include_router(system_settings.router)
# Admin LLM metrics audit + export
app.include_router(llm_metrics.router)
# Channels API is mounted at /api/channels
app.include_router(channels.router)
@@ -62,6 +62,15 @@ class LocalAuthProvider(AuthProvider):
"""Get user by ID."""
return await self._repo.get_user_by_id(user_id)
async def find_user_by_email_local_part(self, local_part: str) -> User | None:
"""Find a user whose email starts with ``local_part@`` regardless of domain.
Used by the passwordless username-login flow to keep a single
username mapped to the same account across legacy / canonical
email-domain defaults.
"""
return await self._repo.find_user_by_email_local_part(local_part)
async def create_user(self, email: str, password: str | None = None, system_role: str = "user", needs_setup: bool = False) -> User:
"""Create a new local user.
@@ -61,6 +61,26 @@ class UserRepository(ABC):
"""
raise NotImplementedError
@abstractmethod
async def find_user_by_email_local_part(self, local_part: str) -> User | None:
"""Find a user by the local part of their email (the bit before ``@``).
Used by the passwordless username-login flow so that a single
username keeps mapping to the same account even when the synthetic
email domain (e.g. ``cm.com`` vs the legacy ``local.deerflow``)
differs between registration and current login.
Returns the oldest matching user when multiple rows share the same
local part, so collisions resolve deterministically.
Args:
local_part: Substring to match before the ``@``. Case-insensitive.
Returns:
User if found, None otherwise.
"""
raise NotImplementedError
@abstractmethod
async def update_user(self, user: User) -> User:
"""Update an existing user.
@@ -88,6 +88,27 @@ class SQLiteUserRepository(UserRepository):
row = result.scalar_one_or_none()
return self._row_to_user(row) if row is not None else None
async def find_user_by_email_local_part(self, local_part: str) -> User | None:
local = (local_part or "").strip().lower()
if not local:
return None
# Escape SQL LIKE wildcards so usernames like ``foo_bar`` or ``a%b`` do
# not match unrelated rows. ``\`` is the chosen escape char below.
escaped = local.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
pattern = f"{escaped}@%"
# Case-insensitive prefix match on the email field, oldest first so a
# subsequent registration cannot shadow an existing account.
stmt = (
select(UserRow)
.where(func.lower(UserRow.email).like(pattern, escape="\\"))
.order_by(UserRow.created_at.asc())
.limit(1)
)
async with self._sf() as session:
result = await session.execute(stmt)
row = result.scalar_one_or_none()
return self._row_to_user(row) if row is not None else None
async def update_user(self, user: User) -> User:
async with self._sf() as session:
row = await session.get(UserRow, str(user.id))
@@ -90,6 +90,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.scheduled_task_store = make_scheduled_task_store(sf)
from deerflow.persistence.llm_metrics import make_llm_metrics_store
app.state.llm_metrics_store = make_llm_metrics_store(sf)
from deerflow.persistence.agents import make_agent_store
app.state.agent_store = make_agent_store(sf)
@@ -158,6 +162,11 @@ def get_scheduled_task_store(request: Request) -> ScheduledTaskStore:
return val
def get_llm_metrics_store(request: Request):
"""Return the LLM metrics store, or None when not configured."""
return getattr(request.app.state, "llm_metrics_store", None)
def get_agent_store(request: Request) -> AgentStore:
"""Return the custom-agent store."""
val = getattr(request.app.state, "agent_store", None)
@@ -161,25 +161,40 @@ class MessageResponse(BaseModel):
# ── Helpers ───────────────────────────────────────────────────────────────
def _normalize_username_local_part(username: str) -> str | None:
"""Return the canonical local-part for a username, or None if user typed an email.
Used both to build the synthetic email and to look up legacy accounts that
were registered with a different default domain. Returning ``None`` for
raw-email input tells callers to skip the domain-agnostic fallback —
if you typed ``alice@old.com``, you meant that specific row.
"""
raw = username.strip()
if not raw:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="username cannot be empty")
if "@" in raw:
return None
normalized = "".join(ch if (ch.isalnum() or ch in "._-") else "-" for ch in raw.lower()).strip("._-")
if not normalized:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="username is invalid")
return normalized
def _username_to_email(username: str) -> str:
"""Map a frontend username to a valid email used by user storage.
- If username already looks like an email (`contains @`), use it directly.
- Otherwise generate `<normalized-username>@<domain>`.
"""
raw = username.strip()
if not raw:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="username cannot be empty")
local = _normalize_username_local_part(username)
if local is None:
return username.strip().lower()
if "@" in raw:
return raw.lower()
normalized = "".join(ch if (ch.isalnum() or ch in "._-") else "-" for ch in raw.lower()).strip("._-")
if not normalized:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="username is invalid")
domain = os.getenv("DEER_FLOW_USERNAME_LOGIN_DOMAIN", "local.deerflow").strip().lower() or "local.deerflow"
return f"{normalized}@{domain}"
# Default mail domain for passwordless / username-only registrations.
# Kept overridable via env so individual deployments can swap it out, but the
# built-in default targets the cm.com tenant.
domain = os.getenv("DEER_FLOW_USERNAME_LOGIN_DOMAIN", "cm.com").strip().lower() or "cm.com"
return f"{local}@{domain}"
def _set_session_cookie(response: Response, token: str, request: Request) -> None:
@@ -365,9 +380,21 @@ async def login_by_username(request: Request, response: Response, body: Username
"""
provider = get_local_provider()
email = _username_to_email(body.username)
local_part = _normalize_username_local_part(body.username)
created = False
user = await provider.get_user_by_email(email)
# Domain-agnostic fallback: if no row exists at the canonical
# ``<username>@<current-default-domain>``, look for an existing user with
# the same local part under any domain. This is the safety net that keeps
# usernames globally unique when the default email domain changes (e.g.
# ``local.deerflow`` → ``cm.com``) — without it, switching the default
# would let the same username log into two different accounts.
if user is None and local_part is not None:
legacy = await provider.find_user_by_email_local_part(local_part)
if legacy is not None:
user = legacy
logger.info("Matched username %r to legacy account %s", body.username, legacy.email)
if user is None:
try:
role = "admin" if body.username == "admin" else "user"
@@ -377,6 +404,8 @@ async def login_by_username(request: Request, response: Response, body: Username
except ValueError:
# Concurrent first login may create the same user in parallel.
user = await provider.get_user_by_email(email)
if user is None and local_part is not None:
user = await provider.find_user_by_email_local_part(local_part)
if user is None:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to create or load user")
elif body.username == "admin" and user.system_role != "admin":
@@ -0,0 +1,323 @@
"""Admin-only LLM call metrics: list + Excel export.
Lets administrators audit every provider invocation made by the runtime —
including failures — and download the full table as ``.xlsx``. The recording
is unconditional on the backend, so this view always reflects every call.
"""
from __future__ import annotations
import logging
from datetime import datetime
from io import BytesIO
from typing import Any
from urllib.parse import quote
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from app.gateway.deps import get_llm_metrics_store, get_local_provider, get_optional_user_from_request
from deerflow.persistence.llm_metrics.base import LlmMetricsQuery, LlmMetricsStore
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/admin/llm-metrics", tags=["llm-metrics"])
class LlmMetricResponse(BaseModel):
id: str
created_at: str | None = None
user_id: str | None = None
user_name: str | None = Field(
default=None,
description="Local part (before @) of the user's email, resolved from user_id",
)
thread_id: str | None = None
run_id: str | None = None
agent_name: str | None = None
model_name: str | None = None
duration_ms: int = 0
input_tokens: int | None = None
output_tokens: int | None = None
total_tokens: int | None = None
tokens_per_sec: float | None = None
status: str = "success"
error_type: str | None = None
error_message: str | None = None
class LlmMetricsListResponse(BaseModel):
items: list[LlmMetricResponse]
total: int = Field(..., description="Total rows matching the filter, ignoring pagination")
limit: int
offset: int
async def _require_admin(request: Request) -> None:
user = await get_optional_user_from_request(request)
if user is None:
return
if getattr(user, "system_role", None) != "admin":
raise HTTPException(status_code=403, detail="LLM metrics admin view is admin-only")
def _require_store(request: Request) -> LlmMetricsStore:
store = get_llm_metrics_store(request)
if store is None:
raise HTTPException(status_code=503, detail="LLM metrics store is not configured")
return store
def _parse_dt(value: str | None) -> datetime | None:
if not value:
return None
try:
# Accept both ``Z`` suffix and ``+00:00``.
return datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError as exc:
raise HTTPException(status_code=422, detail=f"Invalid datetime: {value!r} ({exc})") from exc
def _email_local_part(email: str | None) -> str | None:
if not email:
return None
at = email.find("@")
return email[:at] if at > 0 else email
def _derive_tokens_per_sec(row: dict[str, Any]) -> None:
"""Fill in ``tokens_per_sec`` when the row was recorded before we knew how
to extract ``output_tokens`` from response_metadata.token_usage.
Uses ``output_tokens / elapsed`` when available, otherwise falls back to
``total_tokens / elapsed`` as an approximation. Newer rows already have
the field set during recording, so this only kicks in for legacy data.
"""
if not isinstance(row, dict):
return
if row.get("tokens_per_sec") not in (None, 0):
return
duration_ms = row.get("duration_ms") or 0
if duration_ms <= 0:
return
numerator = row.get("output_tokens") or row.get("total_tokens")
if not numerator:
return
row["tokens_per_sec"] = round((numerator / duration_ms) * 1000, 3)
async def _resolve_user_name_to_id(name: str | None) -> str | None:
"""Look up a UUID for the given username (email local-part)."""
if not name:
return None
try:
provider = get_local_provider()
except Exception:
return None
try:
user = await provider.find_user_by_email_local_part(name)
except Exception:
return None
return str(user.id) if user is not None else None
async def _attach_usernames(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Resolve each row's ``user_id`` UUID to a friendly ``user_name``.
Batches lookups so one query per distinct user_id, not per row. Tolerates
missing accounts (deleted users) by leaving ``user_name`` blank.
"""
if not rows:
return rows
user_ids = {row["user_id"] for row in rows if isinstance(row, dict) and row.get("user_id")}
if not user_ids:
return rows
try:
provider = get_local_provider()
except Exception:
provider = None
email_by_id: dict[str, str] = {}
if provider is not None:
for uid in user_ids:
try:
user = await provider.get_user(uid)
except Exception:
user = None
if user is not None and user.email:
email_by_id[uid] = user.email
for row in rows:
if isinstance(row, dict) and row.get("user_id"):
email = email_by_id.get(row["user_id"])
row["user_name"] = _email_local_part(email) or row["user_id"]
return rows
def _build_query(
*,
user_id: str | None,
model_name: str | None,
status: str | None,
since: str | None,
until: str | None,
limit: int,
offset: int,
) -> LlmMetricsQuery:
return LlmMetricsQuery(
user_id=user_id or None,
model_name=model_name or None,
status=status or None,
since=_parse_dt(since),
until=_parse_dt(until),
limit=limit,
offset=offset,
)
@router.get("", response_model=LlmMetricsListResponse)
async def list_llm_metrics(
request: Request,
user_id: str | None = Query(default=None),
user_name: str | None = Query(default=None, description="Username (email local part) to filter by"),
model_name: str | None = Query(default=None),
status: str | None = Query(default=None, pattern="^(success|error)$"),
since: str | None = Query(default=None, description="ISO 8601 datetime, inclusive lower bound"),
until: str | None = Query(default=None, description="ISO 8601 datetime, inclusive upper bound"),
limit: int = Query(default=50, ge=1, le=500),
offset: int = Query(default=0, ge=0),
) -> LlmMetricsListResponse:
await _require_admin(request)
store = _require_store(request)
if not user_id and user_name:
resolved = await _resolve_user_name_to_id(user_name)
if resolved is None:
# Username unknown → no rows; short-circuit instead of a global scan.
return LlmMetricsListResponse(items=[], total=0, limit=limit, offset=offset)
user_id = resolved
query = _build_query(
user_id=user_id,
model_name=model_name,
status=status,
since=since,
until=until,
limit=limit,
offset=offset,
)
rows = await store.list(query)
total = await store.count(query)
await _attach_usernames(rows)
for row in rows:
_derive_tokens_per_sec(row)
return LlmMetricsListResponse(
items=[LlmMetricResponse(**row) for row in rows],
total=total,
limit=limit,
offset=offset,
)
def _format_cell(value: Any) -> Any:
if value is None:
return ""
if isinstance(value, datetime):
return value.replace(tzinfo=None) if value.tzinfo else value
return value
@router.get("/export")
async def export_llm_metrics(
request: Request,
user_id: str | None = Query(default=None),
user_name: str | None = Query(default=None),
model_name: str | None = Query(default=None),
status: str | None = Query(default=None, pattern="^(success|error)$"),
since: str | None = Query(default=None),
until: str | None = Query(default=None),
):
"""Stream the full filtered metric set as an ``.xlsx`` file."""
await _require_admin(request)
store = _require_store(request)
if not user_id and user_name:
resolved = await _resolve_user_name_to_id(user_name)
if resolved is None:
# No matching user — return an empty xlsx so the user still gets a
# file (matching the list view's empty state).
user_id = "__no_such_user__"
else:
user_id = resolved
try:
from openpyxl import Workbook
except ImportError as exc:
raise HTTPException(status_code=500, detail=f"openpyxl is not installed: {exc}") from exc
query = _build_query(
user_id=user_id,
model_name=model_name,
status=status,
since=since,
until=until,
limit=1, # unused: iter_all paginates internally
offset=0,
)
workbook = Workbook(write_only=True)
sheet = workbook.create_sheet("llm_call_metrics")
columns = [
"id",
"created_at",
"status",
"user_name",
"user_id",
"thread_id",
"run_id",
"agent_name",
"model_name",
"duration_ms",
"input_tokens",
"output_tokens",
"total_tokens",
"tokens_per_sec",
"error_type",
"error_message",
]
sheet.append(columns)
# Pre-resolve all distinct user_ids in chunks so the export doesn't pay one
# round trip per row.
buffered: list[dict[str, Any]] = []
FLUSH_AT = 200
async def flush() -> int:
if not buffered:
return 0
await _attach_usernames(buffered)
for row in buffered:
_derive_tokens_per_sec(row)
sheet.append([_format_cell(row.get(col)) for col in columns])
n = len(buffered)
buffered.clear()
return n
count = 0
async for row in store.iter_all(query):
buffered.append(row)
if len(buffered) >= FLUSH_AT:
count += await flush()
count += await flush()
buffer = BytesIO()
workbook.save(buffer)
buffer.seek(0)
logger.info("Exported %d llm_call_metrics rows", count)
timestamp = datetime.utcnow().strftime("%Y%m%d-%H%M%S")
filename = f"llm_call_metrics-{timestamp}.xlsx"
headers = {
"Content-Disposition": f"attachment; filename=\"{filename}\"; filename*=UTF-8''{quote(filename)}",
}
return StreamingResponse(
buffer,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers=headers,
)
@@ -3,15 +3,31 @@ import logging
from pathlib import Path
from typing import Literal
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from app.gateway.deps import get_optional_user_from_request
from deerflow.config.extensions_config import ExtensionsConfig, get_extensions_config, reload_extensions_config
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["mcp"])
async def _require_admin(request: Request) -> None:
"""Reject the request unless the caller is an authenticated admin user.
Allows the call through silently when auth is disabled (no user attached),
so existing single-user / no-auth deployments keep working.
"""
user = await get_optional_user_from_request(request)
if user is None:
# No authenticated user — auth might be disabled or middleware bypassed.
# Defer to the AuthMiddleware decision and let the call through.
return
if getattr(user, "system_role", None) != "admin":
raise HTTPException(status_code=403, detail="MCP configuration requires admin privileges")
class McpOAuthConfigResponse(BaseModel):
"""OAuth configuration for an MCP server."""
@@ -43,6 +59,10 @@ class McpServerConfigResponse(BaseModel):
headers: dict[str, str] = Field(default_factory=dict, description="HTTP headers to send (for sse or http type)")
oauth: McpOAuthConfigResponse | None = Field(default=None, description="OAuth configuration for MCP HTTP/SSE servers")
description: str = Field(default="", description="Human-readable description of what this MCP server provides")
disabled_tools: list[str] = Field(
default_factory=list,
description="Tool names (without server-name prefix) to hide from the agent",
)
class McpConfigResponse(BaseModel):
@@ -101,7 +121,7 @@ async def get_mcp_configuration() -> McpConfigResponse:
summary="Update MCP Configuration",
description="Update Model Context Protocol (MCP) server configurations and save to file.",
)
async def update_mcp_configuration(request: McpConfigUpdateRequest) -> McpConfigResponse:
async def update_mcp_configuration(request: McpConfigUpdateRequest, http_request: Request) -> McpConfigResponse:
"""Update the MCP configuration.
This will:
@@ -133,6 +153,22 @@ async def update_mcp_configuration(request: McpConfigUpdateRequest) -> McpConfig
}
```
"""
await _require_admin(http_request)
# Light validation: each server must have the fields required by its transport.
for server_name, server in request.mcp_servers.items():
if not server_name.strip():
raise HTTPException(status_code=400, detail="MCP server name cannot be empty")
transport = (server.type or "stdio").lower()
if transport == "stdio":
if not (server.command or "").strip():
raise HTTPException(status_code=400, detail=f"MCP server '{server_name}' uses stdio transport but command is empty")
elif transport in ("http", "sse"):
if not (server.url or "").strip():
raise HTTPException(status_code=400, detail=f"MCP server '{server_name}' uses {transport} transport but url is empty")
else:
raise HTTPException(status_code=400, detail=f"MCP server '{server_name}' has unsupported transport: {transport}")
try:
# Get the current config path (or determine where to save it)
config_path = ExtensionsConfig.resolve_config_path()
@@ -14,7 +14,7 @@ from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import FileResponse, Response, StreamingResponse
from pydantic import BaseModel, Field
from app.gateway.deps import get_current_user
from app.gateway.deps import get_current_user, get_local_provider
from deerflow.runtime.scheduler import get_scheduled_task_service
router = APIRouter(prefix="/api/scheduled-tasks", tags=["scheduled-tasks"])
@@ -61,6 +61,50 @@ async def _user_id(request: Request) -> str:
return user_id
async def _enrich_with_creator(tasks: list[dict[str, Any]] | dict[str, Any] | None) -> Any:
"""Attach ``creator_email`` / ``creator_user_id`` to one or many task dicts.
Looks up the auth user once per distinct ``user_id`` in the batch so we do
not pay N+1 round trips when rendering a long task list. Tolerates missing
user records (deleted accounts) by leaving the email blank.
"""
if tasks is None:
return tasks
items = tasks if isinstance(tasks, list) else [tasks]
user_ids = {str(t.get("user_id")) for t in items if isinstance(t, dict) and t.get("user_id")}
if not user_ids:
return tasks
try:
provider = get_local_provider()
except Exception:
# Auth provider not initialised (e.g. memory-mode test harness): keep
# the bare user_id and let the frontend render the raw id.
provider = None
email_by_id: dict[str, str] = {}
if provider is not None:
for uid in user_ids:
try:
user = await provider.get_user(uid)
except Exception:
user = None
if user is not None:
email_by_id[uid] = user.email
for item in items:
if not isinstance(item, dict):
continue
uid = item.get("user_id")
if uid is None:
continue
uid_str = str(uid)
item["creator_user_id"] = uid_str
item["creator_email"] = email_by_id.get(uid_str, "")
return tasks
def _safe_filename(value: str, default: str = "scheduled-task-result") -> str:
name = re.sub(r'[\\/:*?"<>|\r\n]+', "-", value).strip(" .")
return name or default
@@ -133,7 +177,9 @@ async def get_scheduler_thread(request: Request) -> dict:
async def list_scheduled_tasks(request: Request) -> list[dict]:
service = get_scheduled_task_service()
user_id = await _user_id(request)
return await service.list_tasks(user_id)
tasks = await service.list_tasks(user_id)
await _enrich_with_creator(tasks)
return tasks
@router.get("/{task_id}/runs")
@@ -158,7 +204,10 @@ async def get_scheduled_task_run(run_id: str, request: Request) -> dict:
async def delete_scheduled_task_run(run_id: str, request: Request) -> dict:
service = get_scheduled_task_service()
user_id = await _user_id(request)
deleted = await service.delete_run(user_id, run_id)
try:
deleted = await service.delete_run(user_id, run_id)
except PermissionError as exc:
raise HTTPException(status_code=403, detail=str(exc))
if not deleted:
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
return {"success": True}
@@ -298,7 +347,7 @@ async def update_delivery_profile(body: ScheduledTaskDeliveryProfileUpdateReques
async def create_scheduled_task(body: ScheduledTaskCreateRequest, request: Request) -> dict:
service = get_scheduled_task_service()
user_id = await _user_id(request)
return await service.create_task(
task = await service.create_task(
user_id=user_id,
name=body.name,
prompt=body.prompt,
@@ -307,6 +356,8 @@ async def create_scheduled_task(body: ScheduledTaskCreateRequest, request: Reque
execution_agent_name=body.execution_agent_name,
execution_context=body.execution_context,
)
await _enrich_with_creator(task)
return task
@router.patch("/{task_id}")
@@ -316,6 +367,7 @@ async def update_scheduled_task(task_id: str, body: ScheduledTaskUpdateRequest,
task = await service.update_task(user_id, task_id, body.model_dump(exclude_unset=True))
if task is None:
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
await _enrich_with_creator(task)
return task
@@ -366,7 +418,17 @@ async def trigger_scheduled_task(task_id: str, request: Request) -> dict:
async def list_published_scheduled_tasks(request: Request, limit: int = 50, offset: int = 0) -> list[dict]:
service = get_scheduled_task_service()
await _user_id(request)
return await service.list_published(limit=max(1, min(limit, 200)), offset=max(0, offset))
tasks = await service.list_published(limit=max(1, min(limit, 200)), offset=max(0, offset))
# published list uses owner_user_id; resolve it into a creator_email here too
# so the discovery view can show the author's account.
for task in tasks:
if isinstance(task, dict) and task.get("owner_user_id"):
task["user_id"] = task["owner_user_id"]
await _enrich_with_creator(tasks)
for task in tasks:
if isinstance(task, dict):
task.pop("user_id", None)
return tasks
@router.post("/{task_id}/publish")
@@ -0,0 +1,65 @@
"""Admin-configurable system-wide runtime settings."""
from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from app.gateway.deps import get_optional_user_from_request
from deerflow.config.system_settings import (
PromptPrefixSettings,
SystemSettings,
load_system_settings,
save_system_settings,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/system-settings", tags=["system-settings"])
class PromptPrefixResponse(BaseModel):
enabled: bool = Field(..., description="Whether the toggle is exposed to users")
switch_label: str = Field(..., description="Toggle label shown in the chat input")
prompt_prefix: str = Field(..., description="Prefix text prepended to the first user message")
class PromptPrefixUpdateRequest(BaseModel):
enabled: bool | None = None
switch_label: str | None = None
prompt_prefix: str | None = None
def _to_response(settings: PromptPrefixSettings) -> PromptPrefixResponse:
return PromptPrefixResponse(
enabled=settings.enabled,
switch_label=settings.switch_label,
prompt_prefix=settings.prompt_prefix,
)
async def _require_admin(request: Request) -> None:
user = await get_optional_user_from_request(request)
if user is None:
return
if getattr(user, "system_role", None) != "admin":
raise HTTPException(status_code=403, detail="System settings can only be modified by an admin")
@router.get("/prompt-prefix", response_model=PromptPrefixResponse)
async def get_prompt_prefix() -> PromptPrefixResponse:
"""Return the current prompt-prefix configuration (readable by any user)."""
return _to_response(load_system_settings().prompt_prefix)
@router.put("/prompt-prefix", response_model=PromptPrefixResponse)
async def update_prompt_prefix(body: PromptPrefixUpdateRequest, request: Request) -> PromptPrefixResponse:
"""Update the prompt-prefix configuration. Admin only."""
await _require_admin(request)
current = load_system_settings()
fields = body.model_dump(exclude_unset=True)
updated_prefix = current.prompt_prefix.model_copy(update=fields)
saved = save_system_settings(SystemSettings(prompt_prefix=updated_prefix))
return _to_response(saved.prompt_prefix)
@@ -19,6 +19,7 @@ from langchain_core.messages import HumanMessage
from app.gateway.deps import get_run_context, get_run_manager, get_stream_bridge
from app.gateway.utils import sanitize_log_param
from deerflow.config.system_settings import load_system_settings
from deerflow.runtime import (
END_SENTINEL,
HEARTBEAT_SENTINEL,
@@ -98,6 +99,45 @@ def normalize_input(raw_input: dict[str, Any] | None) -> dict[str, Any]:
_DEFAULT_ASSISTANT_ID = "lead_agent"
def _prepend_prompt_prefix(graph_input: dict[str, Any], prefix: str) -> dict[str, Any]:
"""Return ``graph_input`` with the prefix prepended to its first HumanMessage.
Leaves the input unchanged when there are no messages or the first message
is not a human turn. Idempotent in the sense that it only touches the first
human message, so re-running this with the same prefix would double-prepend
— callers must gate it on first-turn detection.
"""
if not prefix:
return graph_input
messages = graph_input.get("messages")
if not isinstance(messages, list) or not messages:
return graph_input
new_messages = list(messages)
for idx, msg in enumerate(new_messages):
if isinstance(msg, HumanMessage):
existing = msg.content
if isinstance(existing, str):
new_messages[idx] = HumanMessage(content=f"{prefix}\n\n{existing}".strip("\n"))
elif isinstance(existing, list):
# Structured content (text blocks + media). Prepend a new text block.
new_messages[idx] = HumanMessage(content=[{"type": "text", "text": prefix}, *existing])
else:
new_messages[idx] = HumanMessage(content=f"{prefix}\n\n{existing!s}")
break
return {**graph_input, "messages": new_messages}
def _prompt_prefix_opted_in(body: Any) -> bool:
"""Detect whether the caller asked for the prompt prefix to be applied."""
context = getattr(body, "context", None)
if isinstance(context, Mapping) and context.get("prompt_prefix_enabled"):
return True
metadata = getattr(body, "metadata", None)
if isinstance(metadata, Mapping) and metadata.get("prompt_prefix_enabled"):
return True
return False
# Whitelist of run-context keys that the langgraph-compat layer forwards from
# ``body.context`` into the run config. ``config["context"]`` exists in
# LangGraph >=0.6, but these values must be written to both ``configurable``
@@ -340,6 +380,7 @@ async def start_run(
# Upsert thread metadata so the thread appears in /threads/search,
# even for threads that were never explicitly created via POST /threads
# (e.g. stateless runs).
thread_existed = False
try:
existing = await run_ctx.thread_store.get(thread_id)
if existing is None:
@@ -349,12 +390,24 @@ async def start_run(
metadata=body.metadata,
)
else:
thread_existed = True
await run_ctx.thread_store.update_status(thread_id, "running")
except Exception:
logger.warning("Failed to upsert thread_meta for %s (non-fatal)", sanitize_log_param(thread_id))
agent_factory = resolve_agent_factory(body.assistant_id)
graph_input = normalize_input(body.input)
# Server-side safety net for the admin-configurable prompt prefix: when the
# caller opted in via context.prompt_prefix_enabled AND this is the very
# first turn of the thread, prepend the configured prefix to the human
# message. We deliberately skip already-running threads so the prefix is
# never inserted twice — the frontend toggle is a per-message hint, but
# this check is what makes the feature safe against client bugs / replays.
if not thread_existed and _prompt_prefix_opted_in(body):
prefix_settings = load_system_settings().prompt_prefix
if prefix_settings.enabled and prefix_settings.prompt_prefix.strip():
graph_input = _prepend_prompt_prefix(graph_input, prefix_settings.prompt_prefix.strip())
config = build_run_config(thread_id, body.config, body.metadata, assistant_id=body.assistant_id)
# Merge DeerFlow-specific context overrides into both ``configurable`` and ``context``.
@@ -0,0 +1,335 @@
"""Middleware that records per-LLM-call metrics for the admin audit page.
Captures **every** provider invocation regardless of the frontend speed-meter
toggle: the goal is a complete admin-side audit log of LLM usage, including
failures. The user-facing speed indicator (controlled by
``LocalSettings.display.streamSpeedMeterEnabled``) is a separate, purely
cosmetic feature — turning it off does not stop this recording.
Each call ends with one row in ``llm_call_metrics``:
- Successes record duration, token counts, and ``tokens_per_sec``.
- Errors record the duration spent before the failure, the exception class as
``error_type`` and a one-line ``error_message``.
Writes go through a best-effort store: a DB outage logs a warning and the
agent runtime keeps streaming. We intentionally place this middleware
*inside* :class:`LLMErrorHandlingMiddleware` (i.e. nested deeper in the
``wrap_model_call`` chain) so we observe the **real** provider call timings —
not the retry-wrapped successful response.
"""
from __future__ import annotations
import asyncio
import logging
import time
import uuid
from collections.abc import Awaitable, Callable
from datetime import UTC, datetime
from typing import Any, override
from langchain.agents import AgentState
from langchain.agents.middleware import AgentMiddleware
from langchain.agents.middleware.types import (
ModelCallResult,
ModelRequest,
ModelResponse,
)
from langchain_core.messages import AIMessage
from langgraph.errors import GraphBubbleUp
from deerflow.persistence.engine import get_session_factory
from deerflow.persistence.llm_metrics import LlmMetricRecord, make_llm_metrics_store
from deerflow.persistence.llm_metrics.base import LlmMetricsStore
from deerflow.runtime.user_context import get_effective_user_id
logger = logging.getLogger(__name__)
def _now() -> datetime:
return datetime.now(UTC)
def _safe_get_thread_id(request: ModelRequest) -> str | None:
state = getattr(request, "state", None)
if isinstance(state, dict):
thread_id = state.get("thread_id")
if isinstance(thread_id, str) and thread_id:
return thread_id
runtime = getattr(request, "runtime", None)
if runtime is None:
return None
config = getattr(runtime, "config", None)
configurable = (config or {}).get("configurable", {}) if isinstance(config, dict) else {}
thread_id = configurable.get("thread_id") if isinstance(configurable, dict) else None
if isinstance(thread_id, str) and thread_id:
return thread_id
return None
def _safe_get_run_id(request: ModelRequest) -> str | None:
runtime = getattr(request, "runtime", None)
if runtime is None:
return None
context = getattr(runtime, "context", None)
if isinstance(context, dict):
for key in ("run_id", "langgraph_run_id"):
value = context.get(key)
if isinstance(value, str) and value:
return value
config = getattr(runtime, "config", None)
if isinstance(config, dict):
metadata = config.get("metadata") if isinstance(config.get("metadata"), dict) else {}
if isinstance(metadata, dict):
for key in ("run_id", "langgraph_run_id"):
value = metadata.get(key)
if isinstance(value, str) and value:
return value
return None
def _safe_get_model_name(request: ModelRequest) -> str | None:
model = getattr(request, "model", None)
for attr in ("model_name", "model", "name"):
value = getattr(model, attr, None)
if isinstance(value, str) and value:
return value
return None
def _safe_get_agent_name(request: ModelRequest) -> str | None:
runtime = getattr(request, "runtime", None)
if runtime is None:
return None
context = getattr(runtime, "context", None)
if isinstance(context, dict):
for key in ("agent_name", "agent_id"):
value = context.get(key)
if isinstance(value, str) and value:
return value
return None
def _coerce_int(value: Any) -> int | None:
if isinstance(value, bool):
return None
if isinstance(value, int):
return value
if isinstance(value, float):
return int(value)
if isinstance(value, str):
try:
return int(value)
except (TypeError, ValueError):
return None
return None
def _extract_usage(response: Any) -> dict[str, int]:
"""Pull token counts out of an AIMessage, falling back to provider-native shapes.
The handler in ``wrap_model_call`` actually hands us a
:class:`langchain.agents.middleware.types.ModelResponse` dataclass whose
``.result`` is a ``list[BaseMessage]``. Earlier versions of this helper
only matched ``AIMessage`` and ``list``, which meant we silently dropped
every successful call and stored None for input/output/total. To stay
robust we now also handle ``ModelResponse`` (and ``ExtendedModelResponse``,
which wraps a ModelResponse), and we walk the messages list for the first
``AIMessage``.
LangChain normalises usage into ``message.usage_metadata`` with the keys
``input_tokens`` / ``output_tokens`` / ``total_tokens``. Several
OpenAI-compatible providers (DeepSeek, vLLM, some Azure deployments) leave
that dict partial — typically only ``total_tokens`` is populated — and the
real breakdown sits under ``response_metadata.token_usage`` with the
provider-native key names ``prompt_tokens`` / ``completion_tokens``. We
merge both so the admin audit view always has the most complete numbers
available.
"""
message: AIMessage | None = None
def _find_ai(candidate: Any) -> AIMessage | None:
if isinstance(candidate, AIMessage):
return candidate
if isinstance(candidate, list):
for item in candidate:
found = _find_ai(item)
if found is not None:
return found
return None
message = _find_ai(response)
if message is None:
# ModelResponse / ExtendedModelResponse — defined as dataclasses with a
# ``result`` field holding ``list[BaseMessage]``. Recurse via attribute
# access so we don't tightly couple to the dataclass class itself.
for attr in ("result", "model_response"):
inner = getattr(response, attr, None)
if inner is None:
continue
message = _find_ai(inner)
if message is None and attr == "model_response":
inner_result = getattr(inner, "result", None)
message = _find_ai(inner_result)
if message is not None:
break
if message is None:
return {}
out: dict[str, int] = {}
usage = getattr(message, "usage_metadata", None) or {}
if isinstance(usage, dict):
for key in ("input_tokens", "output_tokens", "total_tokens"):
coerced = _coerce_int(usage.get(key))
if coerced is not None:
out[key] = coerced
response_metadata = getattr(message, "response_metadata", None) or {}
token_usage = response_metadata.get("token_usage") if isinstance(response_metadata, dict) else None
if isinstance(token_usage, dict):
# Map both the LangChain-canonical names and OpenAI-native names; later
# writes win so a more-precise provider-native value can override a
# zero placeholder from usage_metadata.
candidates = {
"input_tokens": ("input_tokens", "prompt_tokens"),
"output_tokens": ("output_tokens", "completion_tokens"),
"total_tokens": ("total_tokens",),
}
for canonical, keys in candidates.items():
for key in keys:
coerced = _coerce_int(token_usage.get(key))
if coerced is not None and coerced > 0:
out[canonical] = coerced
break
# Derive a missing or implausibly-zero field if the other two are present.
# Some providers report ``input_tokens=0`` / ``output_tokens=0`` next to a
# real ``total_tokens`` — treat those zeros as missing so we don't silently
# store nonsense breakdowns.
def _missing_or_zero(key: str) -> bool:
return out.get(key) in (None, 0)
if "total_tokens" not in out and out.get("input_tokens") and out.get("output_tokens"):
out["total_tokens"] = out["input_tokens"] + out["output_tokens"]
if _missing_or_zero("output_tokens") and out.get("total_tokens") and out.get("input_tokens"):
derived = out["total_tokens"] - out["input_tokens"]
if derived > 0:
out["output_tokens"] = derived
if _missing_or_zero("input_tokens") and out.get("total_tokens") and out.get("output_tokens"):
derived = out["total_tokens"] - out["output_tokens"]
if derived > 0:
out["input_tokens"] = derived
return out
def _classify_exception(exc: BaseException) -> tuple[str, str]:
"""Return (error_type, error_message). Message is trimmed to 500 chars."""
name = exc.__class__.__name__
raw = (str(exc) or name).strip()
return name, (raw[:500] if len(raw) > 500 else raw)
class LlmMetricsMiddleware(AgentMiddleware[AgentState]):
"""Wrap each model call to time it and persist a metric row."""
def __init__(self, *, store: LlmMetricsStore | None = None, **kwargs: Any) -> None:
super().__init__(**kwargs)
self._store_override = store
def _store(self) -> LlmMetricsStore | None:
if self._store_override is not None:
return self._store_override
try:
sf = get_session_factory()
except Exception:
return None
return make_llm_metrics_store(sf)
def _build_base_record(self, request: ModelRequest) -> LlmMetricRecord:
return LlmMetricRecord(
id=uuid.uuid4().hex,
created_at=_now(),
user_id=get_effective_user_id(),
thread_id=_safe_get_thread_id(request),
run_id=_safe_get_run_id(request),
agent_name=_safe_get_agent_name(request),
model_name=_safe_get_model_name(request),
)
def _finalize_success(self, record: LlmMetricRecord, response: ModelResponse, started: float) -> None:
elapsed = max(0.0, time.monotonic() - started)
record.duration_ms = int(elapsed * 1000)
usage = _extract_usage(response)
record.input_tokens = usage.get("input_tokens")
record.output_tokens = usage.get("output_tokens")
record.total_tokens = usage.get("total_tokens") or (
(record.input_tokens or 0) + (record.output_tokens or 0) or None
)
if record.output_tokens is not None and elapsed > 0:
record.tokens_per_sec = round(record.output_tokens / elapsed, 3)
record.status = "success"
def _finalize_error(self, record: LlmMetricRecord, exc: BaseException, started: float) -> None:
record.duration_ms = int(max(0.0, time.monotonic() - started) * 1000)
record.status = "error"
record.error_type, record.error_message = _classify_exception(exc)
def _flush(self, record: LlmMetricRecord) -> None:
store = self._store()
if store is None:
return
try:
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = None
if loop is not None and loop.is_running():
# Fire-and-forget — never let metric IO block the request path.
loop.create_task(store.record(record))
else:
asyncio.run(store.record(record))
except Exception:
logger.warning("LlmMetricsMiddleware: failed to flush metric %s", record.id, exc_info=True)
@override
def wrap_model_call(
self,
request: ModelRequest,
handler: Callable[[ModelRequest], ModelResponse],
) -> ModelCallResult:
record = self._build_base_record(request)
started = time.monotonic()
try:
response = handler(request)
except GraphBubbleUp:
# LangGraph control flow — not a real LLM failure.
raise
except Exception as exc:
self._finalize_error(record, exc, started)
self._flush(record)
raise
self._finalize_success(record, response, started)
self._flush(record)
return response
@override
async def awrap_model_call(
self,
request: ModelRequest,
handler: Callable[[ModelRequest], Awaitable[ModelResponse]],
) -> ModelCallResult:
record = self._build_base_record(request)
started = time.monotonic()
try:
response = await handler(request)
except GraphBubbleUp:
raise
except Exception as exc:
self._finalize_error(record, exc, started)
self._flush(record)
raise
self._finalize_success(record, response, started)
self._flush(record)
return response
@@ -96,6 +96,14 @@ def _build_runtime_middlewares(
middlewares.append(LLMErrorHandlingMiddleware(app_config=app_config))
# LLM call metrics — always on, even when the user-facing speed indicator
# is disabled. We append *after* the retry middleware so the metrics
# middleware sits deeper in the wrap chain and observes individual provider
# attempts (including the failed ones).
from deerflow.agents.middlewares.llm_metrics_middleware import LlmMetricsMiddleware
middlewares.append(LlmMetricsMiddleware())
# Guardrail middleware (if configured)
guardrails_config = app_config.guardrails
if guardrails_config.enabled and guardrails_config.provider:
@@ -45,7 +45,12 @@ class McpServerConfig(BaseModel):
headers: dict[str, str] = Field(default_factory=dict, description="HTTP headers to send (for sse or http type)")
oauth: McpOAuthConfig | None = Field(default=None, description="OAuth configuration (for sse or http type)")
description: str = Field(default="", description="Human-readable description of what this MCP server provides")
model_config = ConfigDict(extra="allow")
disabled_tools: list[str] = Field(
default_factory=list,
description="List of tool names (without server-name prefix) to hide from the agent",
alias="disabledTools",
)
model_config = ConfigDict(extra="allow", populate_by_name=True)
class SkillStateConfig(BaseModel):
@@ -0,0 +1,77 @@
"""System-wide runtime settings editable from the admin UI.
Backed by a single JSON file under the DeerFlow runtime home (default
``.deer-flow/system_settings.json``). Unlike :mod:`extensions_config` this
file is **not** part of the project source tree — it is per-deployment
state, and it stays writable at runtime.
The only feature stored here at the moment is the **prompt prefix toggle**
shown in the chat input: an administrator can configure a label and a
prefix string, and any user can flip the toggle on/off; when enabled, the
prefix is prepended to the first human message of a new thread.
"""
from __future__ import annotations
import json
import logging
import threading
from pathlib import Path
from pydantic import BaseModel, ConfigDict, Field
from deerflow.config.runtime_paths import runtime_home
logger = logging.getLogger(__name__)
_SETTINGS_FILE = "system_settings.json"
_lock = threading.Lock()
class PromptPrefixSettings(BaseModel):
"""Admin-configurable first-message prompt prefix."""
enabled: bool = Field(default=False, description="Whether the prefix toggle is offered to users at all")
switch_label: str = Field(default="加入立场前缀", description="Toggle label shown in the chat input")
prompt_prefix: str = Field(default="", description="Text prepended to the first user message when enabled")
model_config = ConfigDict(extra="ignore")
class SystemSettings(BaseModel):
"""Top-level system settings document."""
prompt_prefix: PromptPrefixSettings = Field(default_factory=PromptPrefixSettings)
model_config = ConfigDict(extra="ignore")
def _settings_path() -> Path:
home = runtime_home()
home.mkdir(parents=True, exist_ok=True)
return home / _SETTINGS_FILE
def load_system_settings() -> SystemSettings:
"""Read the on-disk system settings, returning defaults when missing."""
path = _settings_path()
if not path.is_file():
return SystemSettings()
try:
raw = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(raw, dict):
logger.warning("System settings file %s is not a JSON object; ignoring", path)
return SystemSettings()
return SystemSettings.model_validate(raw)
except Exception as exc:
logger.warning("Failed to read system settings from %s: %s", path, exc)
return SystemSettings()
def save_system_settings(settings: SystemSettings) -> SystemSettings:
"""Persist the settings document atomically, returning the saved value."""
path = _settings_path()
payload = settings.model_dump(mode="json")
with _lock:
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
tmp.replace(path)
return settings
@@ -121,6 +121,25 @@ async def get_mcp_tools() -> list[BaseTool]:
# Get all tools from all servers
tools = await client.get_tools()
# Filter out tools listed in each server's `disabled_tools`. Tool names from
# MultiServerMCPClient are prefixed with the server name (because we pass
# `tool_name_prefix=True`), so we compare against `<server>_<tool>` here.
disabled_full_names: set[str] = set()
for server_name, server_cfg in extensions_config.get_enabled_mcp_servers().items():
for tool_name in server_cfg.disabled_tools or []:
name = (tool_name or "").strip()
if not name:
continue
disabled_full_names.add(f"{server_name}_{name}")
disabled_full_names.add(name)
if disabled_full_names:
kept = [t for t in tools if t.name not in disabled_full_names]
removed = len(tools) - len(kept)
if removed:
logger.info(f"Filtered out {removed} disabled MCP tool(s)")
tools = kept
logger.info(f"Successfully loaded {len(tools)} tool(s) from MCP servers")
# Patch tools to support sync invocation, as deerflow client streams synchronously
@@ -0,0 +1,25 @@
"""Persistence layer for per-LLM-call metrics.
Each completed LLM invocation (success or failure) writes a single row so
administrators can audit usage and speed. Designed to be cheap to write —
the middleware fires once per provider call and never blocks streaming.
"""
from deerflow.persistence.llm_metrics.base import LlmMetricRecord, LlmMetricsStore
from deerflow.persistence.llm_metrics.model import LlmCallMetricRow
from deerflow.persistence.llm_metrics.sql import SqlLlmMetricsStore
__all__ = [
"LlmCallMetricRow",
"LlmMetricRecord",
"LlmMetricsStore",
"SqlLlmMetricsStore",
"make_llm_metrics_store",
]
def make_llm_metrics_store(session_factory) -> LlmMetricsStore | None:
"""Return a SQL-backed metrics store, or ``None`` when no DB is configured."""
if session_factory is None:
return None
return SqlLlmMetricsStore(session_factory)
@@ -0,0 +1,66 @@
"""Abstract repository contract for LLM call metrics."""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
from datetime import datetime
from typing import Any
@dataclass
class LlmMetricRecord:
"""In-memory shape of a metric row before persistence."""
id: str
created_at: datetime
user_id: str | None = None
thread_id: str | None = None
run_id: str | None = None
agent_name: str | None = None
model_name: str | None = None
duration_ms: int = 0
input_tokens: int | None = None
output_tokens: int | None = None
total_tokens: int | None = None
tokens_per_sec: float | None = None
status: str = "success"
error_type: str | None = None
error_message: str | None = None
@dataclass
class LlmMetricsQuery:
"""Filter set for the admin list / export endpoints."""
user_id: str | None = None
model_name: str | None = None
status: str | None = None
since: datetime | None = None
until: datetime | None = None
limit: int = 100
offset: int = 0
class LlmMetricsStore(ABC):
"""Persistence contract for the metrics middleware + admin API."""
@abstractmethod
async def record(self, metric: LlmMetricRecord) -> None:
"""Persist one metric row. Must never raise on best-effort writes."""
raise NotImplementedError
@abstractmethod
async def list(self, query: LlmMetricsQuery) -> list[dict[str, Any]]:
"""Return the matching rows (newest first), paginated by ``query``."""
raise NotImplementedError
@abstractmethod
async def count(self, query: LlmMetricsQuery) -> int:
"""Return the unpaginated row count for ``query``."""
raise NotImplementedError
@abstractmethod
async def iter_all(self, query: LlmMetricsQuery):
"""Yield rows matching ``query`` without pagination — used by export."""
raise NotImplementedError
@@ -0,0 +1,62 @@
"""ORM model for per-LLM-call metrics."""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import DateTime, Float, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
class LlmCallMetricRow(Base):
"""One row per completed (or failed) LLM provider invocation."""
__tablename__ = "llm_call_metrics"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
# Wall-clock time the LLM call ended. Indexed because admin views always
# sort/filter by recency.
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC), index=True)
user_id: Mapped[str | None] = mapped_column(String(64), index=True)
thread_id: Mapped[str | None] = mapped_column(String(64), index=True)
run_id: Mapped[str | None] = mapped_column(String(64), index=True)
agent_name: Mapped[str | None] = mapped_column(String(128), index=True)
model_name: Mapped[str | None] = mapped_column(String(128), index=True)
# Wall-clock duration of the provider invocation in milliseconds. Always set.
duration_ms: Mapped[int] = mapped_column(Integer, default=0)
input_tokens: Mapped[int | None] = mapped_column(Integer)
output_tokens: Mapped[int | None] = mapped_column(Integer)
total_tokens: Mapped[int | None] = mapped_column(Integer)
# Output tokens / seconds; null when there is no completion (errors).
tokens_per_sec: Mapped[float | None] = mapped_column(Float)
# "success" or "error". Errors store the classification and original message
# so admins can scan for transient vs auth/quota issues.
status: Mapped[str] = mapped_column(String(16), default="success", index=True)
error_type: Mapped[str | None] = mapped_column(String(64))
error_message: Mapped[str | None] = mapped_column(Text)
def to_dict(self) -> dict:
return {
"id": self.id,
"created_at": self.created_at.isoformat() if isinstance(self.created_at, datetime) else self.created_at,
"user_id": self.user_id,
"thread_id": self.thread_id,
"run_id": self.run_id,
"agent_name": self.agent_name,
"model_name": self.model_name,
"duration_ms": self.duration_ms,
"input_tokens": self.input_tokens,
"output_tokens": self.output_tokens,
"total_tokens": self.total_tokens,
"tokens_per_sec": self.tokens_per_sec,
"status": self.status,
"error_type": self.error_type,
"error_message": self.error_message,
}
@@ -0,0 +1,109 @@
"""SQLAlchemy-backed LLM metrics repository."""
from __future__ import annotations
import logging
from collections.abc import AsyncIterator
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.llm_metrics.base import LlmMetricRecord, LlmMetricsQuery, LlmMetricsStore
from deerflow.persistence.llm_metrics.model import LlmCallMetricRow
logger = logging.getLogger(__name__)
def _row_to_dict(row: LlmCallMetricRow) -> dict[str, Any]:
return row.to_dict()
def _normalize_created_at(value: datetime) -> datetime:
return value if value.tzinfo else value.replace(tzinfo=UTC)
class SqlLlmMetricsStore(LlmMetricsStore):
"""SQLite/Postgres-backed implementation; writes are intentionally fire-and-forget."""
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
async def record(self, metric: LlmMetricRecord) -> None:
row = LlmCallMetricRow(
id=metric.id,
created_at=_normalize_created_at(metric.created_at),
user_id=metric.user_id,
thread_id=metric.thread_id,
run_id=metric.run_id,
agent_name=metric.agent_name,
model_name=metric.model_name,
duration_ms=metric.duration_ms,
input_tokens=metric.input_tokens,
output_tokens=metric.output_tokens,
total_tokens=metric.total_tokens,
tokens_per_sec=metric.tokens_per_sec,
status=metric.status,
error_type=metric.error_type,
error_message=metric.error_message,
)
try:
async with self._sf() as session:
session.add(row)
await session.commit()
except Exception:
# Metrics are best-effort. Never let a persistence failure surface
# to the agent runtime — log and move on.
logger.warning("Failed to persist llm_call_metrics row %s", metric.id, exc_info=True)
def _apply_filters(self, stmt, query: LlmMetricsQuery):
if query.user_id:
stmt = stmt.where(LlmCallMetricRow.user_id == query.user_id)
if query.model_name:
stmt = stmt.where(LlmCallMetricRow.model_name == query.model_name)
if query.status:
stmt = stmt.where(LlmCallMetricRow.status == query.status)
if query.since is not None:
stmt = stmt.where(LlmCallMetricRow.created_at >= _normalize_created_at(query.since))
if query.until is not None:
stmt = stmt.where(LlmCallMetricRow.created_at <= _normalize_created_at(query.until))
return stmt
async def list(self, query: LlmMetricsQuery) -> list[dict[str, Any]]:
stmt = select(LlmCallMetricRow)
stmt = self._apply_filters(stmt, query)
stmt = stmt.order_by(desc(LlmCallMetricRow.created_at)).limit(max(1, min(query.limit, 1000))).offset(max(0, query.offset))
async with self._sf() as session:
result = await session.execute(stmt)
return [_row_to_dict(row) for row in result.scalars()]
async def count(self, query: LlmMetricsQuery) -> int:
stmt = select(func.count()).select_from(LlmCallMetricRow)
stmt = self._apply_filters(stmt, query)
async with self._sf() as session:
return (await session.scalar(stmt)) or 0
async def iter_all(self, query: LlmMetricsQuery) -> AsyncIterator[dict[str, Any]]:
# Page through the result so the Excel export stays bounded in memory
# for large date windows.
page_size = 500
offset = 0
while True:
page_query = LlmMetricsQuery(
user_id=query.user_id,
model_name=query.model_name,
status=query.status,
since=query.since,
until=query.until,
limit=page_size,
offset=offset,
)
page = await self.list(page_query)
if not page:
return
for item in page:
yield item
if len(page) < page_size:
return
offset += page_size
@@ -0,0 +1,59 @@
"""Create llm_call_metrics table.
Revision ID: 20260514_02
Revises: 20260514_01
Create Date: 2026-05-14
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260514_02"
down_revision = "20260514_01"
branch_labels = None
depends_on = None
def _has_table(table_name: str) -> bool:
inspector = inspect(op.get_bind())
return table_name in inspector.get_table_names()
def upgrade() -> None:
if _has_table("llm_call_metrics"):
return
op.create_table(
"llm_call_metrics",
sa.Column("id", sa.String(64), primary_key=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("user_id", sa.String(64), nullable=True),
sa.Column("thread_id", sa.String(64), nullable=True),
sa.Column("run_id", sa.String(64), nullable=True),
sa.Column("agent_name", sa.String(128), nullable=True),
sa.Column("model_name", sa.String(128), nullable=True),
sa.Column("duration_ms", sa.Integer(), nullable=False, server_default="0"),
sa.Column("input_tokens", sa.Integer(), nullable=True),
sa.Column("output_tokens", sa.Integer(), nullable=True),
sa.Column("total_tokens", sa.Integer(), nullable=True),
sa.Column("tokens_per_sec", sa.Float(), nullable=True),
sa.Column("status", sa.String(16), nullable=False, server_default="success"),
sa.Column("error_type", sa.String(64), nullable=True),
sa.Column("error_message", sa.Text(), nullable=True),
)
op.create_index("ix_llm_call_metrics_created_at", "llm_call_metrics", ["created_at"])
op.create_index("ix_llm_call_metrics_user_id", "llm_call_metrics", ["user_id"])
op.create_index("ix_llm_call_metrics_model_name", "llm_call_metrics", ["model_name"])
op.create_index("ix_llm_call_metrics_status", "llm_call_metrics", ["status"])
op.create_index("ix_llm_call_metrics_thread_id", "llm_call_metrics", ["thread_id"])
op.create_index("ix_llm_call_metrics_run_id", "llm_call_metrics", ["run_id"])
op.create_index("ix_llm_call_metrics_agent_name", "llm_call_metrics", ["agent_name"])
def downgrade() -> None:
if _has_table("llm_call_metrics"):
op.drop_table("llm_call_metrics")
@@ -17,7 +17,9 @@ there is no matching entity directory.
from deerflow.persistence.agents.model import AgentRow
from deerflow.persistence.feedback.model import FeedbackRow
from deerflow.persistence.llm_metrics.model import LlmCallMetricRow
from deerflow.persistence.models.run_event import RunEventRow
from deerflow.persistence.recommended_questions.model import RecommendedQuestionRow
from deerflow.persistence.run.model import RunRow
from deerflow.persistence.scheduled_tasks.model import (
ScheduledTaskDeliveryProfileRow,
@@ -32,6 +34,8 @@ from deerflow.persistence.user.model import UserRow
__all__ = [
"FeedbackRow",
"AgentRow",
"LlmCallMetricRow",
"RecommendedQuestionRow",
"RunEventRow",
"RunRow",
"ScheduledTaskDeliveryProfileRow",
@@ -247,7 +247,11 @@ class ScheduledTaskService:
accessible = await self._accessible_task(user_id, task_id)
if accessible is None:
return []
return await self.store.list_runs(task_id, accessible["user_id"], limit=limit)
runs = await self.store.list_runs(task_id, accessible["user_id"], limit=limit)
can_edit = accessible.get("role") == "owner"
for run in runs:
run["can_edit"] = can_edit
return runs
async def get_run(self, user_id: str, run_id: str) -> dict[str, Any] | None:
run = await self.store.get_run(run_id, user_id)
@@ -316,7 +320,7 @@ class ScheduledTaskService:
except ValueError as exc:
raise PermissionError("Refusing to edit file outside the run archive") from exc
archive_path.write_text(new_content, encoding="utf-8")
archive_path.write_text(new_content, encoding="utf-8", newline="")
new_size = archive_path.stat().st_size
# Reflect the new size in the run row so subsequent list/preview calls
@@ -342,6 +346,10 @@ class ScheduledTaskService:
shutil.rmtree(archive_dir)
except Exception:
logger.warning("Failed to delete scheduled task run archive %s", archive_dir, exc_info=True)
return True
candidate = await self.store.get_run_any(run_id)
if candidate is not None and await self.store.get_subscription(candidate["task_id"], user_id) is not None:
raise PermissionError("Only the run owner may delete scheduled task runs")
return deleted
async def get_delivery_profile(self, user_id: str) -> dict[str, Any]:
@@ -0,0 +1,321 @@
"""Tests for the LLM call metrics middleware + repository.
Covers:
- Successful calls emit a row with duration, token counts and tokens_per_sec.
- Failed calls record the exception class as ``error_type`` plus a trimmed
``error_message``.
- The middleware never blocks on a backend exception (best-effort writes).
- The SQL store applies filters and pagination for the admin list endpoint.
"""
from __future__ import annotations
import asyncio
import uuid
from datetime import UTC, datetime, timedelta
import pytest
import pytest_asyncio
from langchain_core.messages import AIMessage
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from deerflow.agents.middlewares.llm_metrics_middleware import (
LlmMetricsMiddleware,
_extract_usage,
)
from deerflow.persistence.base import Base
from deerflow.persistence.llm_metrics import SqlLlmMetricsStore
from deerflow.persistence.llm_metrics.base import LlmMetricRecord, LlmMetricsQuery
class _RecordingStore:
"""Minimal in-memory store used to assert what the middleware persists."""
def __init__(self) -> None:
self.records: list[LlmMetricRecord] = []
async def record(self, metric: LlmMetricRecord) -> None:
self.records.append(metric)
async def list(self, query: LlmMetricsQuery):
return [r.__dict__ for r in self.records]
async def count(self, query: LlmMetricsQuery) -> int:
return len(self.records)
async def iter_all(self, query: LlmMetricsQuery):
for record in self.records:
yield record.__dict__
@pytest_asyncio.fixture
async def sql_store(tmp_path):
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'metrics.db'}")
async with engine.begin() as conn:
# Importing the package registers the LlmCallMetricRow with Base.metadata.
import deerflow.persistence.llm_metrics.model # noqa: F401
await conn.run_sync(Base.metadata.create_all)
sf = async_sessionmaker(engine, expire_on_commit=False)
store = SqlLlmMetricsStore(sf)
try:
yield store
finally:
await engine.dispose()
def _make_request():
return type(
"FakeRequest",
(),
{
"model": type("FakeModel", (), {"model_name": "gpt-test"}),
"runtime": type(
"FakeRuntime",
(),
{
"config": {"configurable": {"thread_id": "t1"}},
"context": {"agent_name": "lead_agent", "run_id": "r1"},
},
),
"state": {"thread_id": "t1"},
},
)()
def test_middleware_records_success_with_speed():
import time as _time
store = _RecordingStore()
mw = LlmMetricsMiddleware(store=store)
response = AIMessage(
content="hi",
usage_metadata={"input_tokens": 5, "output_tokens": 20, "total_tokens": 25},
)
def slow_handler(_req):
# Force a measurable elapsed window so tokens_per_sec is populated.
# 50ms is comfortably above Windows monotonic-clock granularity.
_time.sleep(0.05)
return response
result = mw.wrap_model_call(_make_request(), slow_handler)
assert result is response
# awrap_model_call uses a fire-and-forget task; here the sync path runs
# asyncio.run synchronously, so the record landed already.
assert len(store.records) == 1
record = store.records[0]
assert record.status == "success"
assert record.model_name == "gpt-test"
assert record.thread_id == "t1"
assert record.run_id == "r1"
assert record.input_tokens == 5
assert record.output_tokens == 20
assert record.total_tokens == 25
# tokens_per_sec may be very high in tests because the handler is
# ~instant; we only assert that the field is populated.
assert record.tokens_per_sec is not None
assert record.duration_ms >= 0
def test_middleware_records_error_with_classification():
store = _RecordingStore()
mw = LlmMetricsMiddleware(store=store)
class BoomError(RuntimeError):
pass
def explode(_req):
raise BoomError("provider exploded")
with pytest.raises(BoomError):
mw.wrap_model_call(_make_request(), explode)
assert len(store.records) == 1
record = store.records[0]
assert record.status == "error"
assert record.error_type == "BoomError"
assert record.error_message == "provider exploded"
assert record.tokens_per_sec is None
def test_middleware_swallows_store_errors():
"""A persistence outage must not break the agent runtime."""
class FaultyStore:
async def record(self, metric):
raise RuntimeError("disk gone")
async def list(self, q):
return []
async def count(self, q):
return 0
async def iter_all(self, q):
if False:
yield {}
mw = LlmMetricsMiddleware(store=FaultyStore())
# The wrap returns normally even though _flush would raise; the call below
# must not raise.
response = AIMessage(
content="ok",
usage_metadata={"input_tokens": 1, "output_tokens": 1, "total_tokens": 2},
)
out = mw.wrap_model_call(_make_request(), lambda req: response)
assert out is response
@pytest.mark.asyncio
async def test_sql_store_round_trip(sql_store):
base = datetime.now(UTC)
for i in range(3):
await sql_store.record(
LlmMetricRecord(
id=uuid.uuid4().hex,
created_at=base - timedelta(minutes=i),
user_id="alice",
model_name="gpt-test" if i % 2 == 0 else "gpt-other",
duration_ms=100 * (i + 1),
output_tokens=10 * (i + 1),
tokens_per_sec=10.0 * (i + 1),
status="success",
)
)
rows = await sql_store.list(LlmMetricsQuery(limit=10))
assert len(rows) == 3
# Newest first.
assert rows[0]["duration_ms"] == 100
filtered = await sql_store.list(LlmMetricsQuery(model_name="gpt-test", limit=10))
assert all(r["model_name"] == "gpt-test" for r in filtered)
assert len(filtered) == 2
assert await sql_store.count(LlmMetricsQuery(model_name="gpt-test")) == 2
@pytest.mark.asyncio
async def test_sql_store_filters_errors_only(sql_store):
base = datetime.now(UTC)
await sql_store.record(
LlmMetricRecord(id=uuid.uuid4().hex, created_at=base, status="success", duration_ms=10, output_tokens=1)
)
await sql_store.record(
LlmMetricRecord(
id=uuid.uuid4().hex,
created_at=base,
status="error",
duration_ms=5,
error_type="APITimeoutError",
error_message="timed out",
)
)
only_errors = await sql_store.list(LlmMetricsQuery(status="error", limit=10))
assert len(only_errors) == 1
assert only_errors[0]["error_type"] == "APITimeoutError"
@pytest.mark.asyncio
async def test_sql_store_iter_all_paginates(sql_store):
base = datetime.now(UTC)
for i in range(1200):
await sql_store.record(
LlmMetricRecord(
id=uuid.uuid4().hex,
created_at=base - timedelta(seconds=i),
duration_ms=1,
status="success",
)
)
seen = 0
async for _ in sql_store.iter_all(LlmMetricsQuery()):
seen += 1
assert seen == 1200
def test_extract_usage_prefers_usage_metadata():
response = AIMessage(
content="x",
usage_metadata={"input_tokens": 3, "output_tokens": 7, "total_tokens": 10},
)
out = _extract_usage(response)
assert out == {"input_tokens": 3, "output_tokens": 7, "total_tokens": 10}
def test_extract_usage_falls_back_to_response_metadata_token_usage():
"""DeepSeek / vLLM-style: only total in usage_metadata, breakdown elsewhere."""
response = AIMessage(
content="x",
usage_metadata={"input_tokens": 0, "output_tokens": 0, "total_tokens": 11093},
response_metadata={
"token_usage": {
"prompt_tokens": 9000,
"completion_tokens": 2093,
"total_tokens": 11093,
},
},
)
out = _extract_usage(response)
assert out["input_tokens"] == 9000
assert out["output_tokens"] == 2093
assert out["total_tokens"] == 11093
def test_extract_usage_unwraps_model_response():
"""The handler hands us a ModelResponse dataclass, not a bare AIMessage."""
from langchain.agents.middleware.types import ModelResponse
inner = AIMessage(
content="hi",
usage_metadata={"input_tokens": 7, "output_tokens": 9, "total_tokens": 16},
)
response = ModelResponse(result=[inner])
out = _extract_usage(response)
assert out == {"input_tokens": 7, "output_tokens": 9, "total_tokens": 16}
def test_extract_usage_unwraps_extended_model_response():
"""ExtendedModelResponse wraps a ModelResponse in a ``model_response`` field."""
from langchain.agents.middleware.types import ExtendedModelResponse, ModelResponse
inner = AIMessage(
content="hi",
usage_metadata={"input_tokens": 1, "output_tokens": 2, "total_tokens": 3},
)
extended = ExtendedModelResponse(model_response=ModelResponse(result=[inner]))
out = _extract_usage(extended)
assert out["output_tokens"] == 2
def test_extract_usage_derives_missing_field_when_two_present():
response = AIMessage(
content="x",
usage_metadata={"input_tokens": 9000, "output_tokens": 0, "total_tokens": 11093},
)
out = _extract_usage(response)
# output should be derived as total - input = 2093.
assert out["output_tokens"] == 2093
@pytest.mark.asyncio
async def test_async_middleware_records_success():
store = _RecordingStore()
mw = LlmMetricsMiddleware(store=store)
async def handler(_req):
return AIMessage(
content="hi",
usage_metadata={"input_tokens": 1, "output_tokens": 4, "total_tokens": 5},
)
response = await mw.awrap_model_call(_make_request(), handler)
assert isinstance(response, AIMessage)
# awrap_model_call schedules the write as an asyncio.task; give the loop a
# chance to drain.
await asyncio.sleep(0)
assert len(store.records) == 1
assert store.records[0].status == "success"
assert store.records[0].output_tokens == 4
@@ -0,0 +1,108 @@
"""Tests for the admin-configurable prompt-prefix toggle."""
from __future__ import annotations
import json
from pathlib import Path
from types import SimpleNamespace
import pytest
from langchain_core.messages import AIMessage, HumanMessage
from app.gateway.services import _prepend_prompt_prefix, _prompt_prefix_opted_in
from deerflow.config import system_settings as ss
@pytest.fixture
def tmp_runtime_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
"""Redirect ``runtime_home()`` to a per-test tmp dir."""
def fake_home() -> Path:
return tmp_path
monkeypatch.setattr(ss, "runtime_home", fake_home)
return tmp_path
def test_load_returns_defaults_when_missing(tmp_runtime_home: Path) -> None:
settings = ss.load_system_settings()
assert settings.prompt_prefix.enabled is False
assert settings.prompt_prefix.prompt_prefix == ""
def test_save_then_load_roundtrips(tmp_runtime_home: Path) -> None:
settings = ss.SystemSettings(
prompt_prefix=ss.PromptPrefixSettings(
enabled=True,
switch_label="加入立场前缀",
prompt_prefix="你需要站在中方角度",
)
)
ss.save_system_settings(settings)
file_path = tmp_runtime_home / "system_settings.json"
assert file_path.is_file()
saved = json.loads(file_path.read_text(encoding="utf-8"))
assert saved["prompt_prefix"]["prompt_prefix"] == "你需要站在中方角度"
reloaded = ss.load_system_settings()
assert reloaded.prompt_prefix.enabled is True
assert reloaded.prompt_prefix.switch_label == "加入立场前缀"
assert reloaded.prompt_prefix.prompt_prefix == "你需要站在中方角度"
def test_prepend_prefix_on_string_content() -> None:
graph_input = {"messages": [HumanMessage(content="评价一下中美贸易")]}
out = _prepend_prompt_prefix(graph_input, "你需要站在中方角度")
msg = out["messages"][0]
assert isinstance(msg, HumanMessage)
assert msg.content == "你需要站在中方角度\n\n评价一下中美贸易"
def test_prepend_prefix_on_block_content() -> None:
graph_input = {
"messages": [
HumanMessage(content=[{"type": "text", "text": "评价一下中美贸易"}])
]
}
out = _prepend_prompt_prefix(graph_input, "你需要站在中方角度")
blocks = out["messages"][0].content
assert isinstance(blocks, list)
assert blocks[0] == {"type": "text", "text": "你需要站在中方角度"}
assert blocks[1] == {"type": "text", "text": "评价一下中美贸易"}
def test_prepend_prefix_only_touches_first_human() -> None:
graph_input = {
"messages": [
AIMessage(content="hi"),
HumanMessage(content="q1"),
HumanMessage(content="q2"),
]
}
out = _prepend_prompt_prefix(graph_input, "PREFIX")
# first human is index 1; index 2 must remain untouched
assert out["messages"][1].content == "PREFIX\n\nq1"
assert out["messages"][2].content == "q2"
def test_prepend_prefix_no_messages_is_noop() -> None:
assert _prepend_prompt_prefix({"messages": []}, "X") == {"messages": []}
assert _prepend_prompt_prefix({}, "X") == {}
def test_opted_in_via_context() -> None:
body = SimpleNamespace(context={"prompt_prefix_enabled": True}, metadata=None)
assert _prompt_prefix_opted_in(body) is True
def test_opted_in_via_metadata() -> None:
body = SimpleNamespace(context=None, metadata={"prompt_prefix_enabled": True})
assert _prompt_prefix_opted_in(body) is True
def test_not_opted_in_by_default() -> None:
body = SimpleNamespace(context=None, metadata=None)
assert _prompt_prefix_opted_in(body) is False
body2 = SimpleNamespace(context={"mode": "ultra"}, metadata={})
assert _prompt_prefix_opted_in(body2) is False
@@ -147,13 +147,23 @@ async def test_subscriber_can_read_runs_and_get_run(store):
)
assert run is not None
alice_runs = await service.list_runs("alice", "t1", limit=10)
assert alice_runs[0]["can_edit"] is True
# Bob (subscriber) sees runs on the task.
bob_runs = await service.list_runs("bob", "t1", limit=10)
assert [r["id"] for r in bob_runs] == ["run-1"]
assert bob_runs[0]["can_edit"] is False
# Bob can fetch the run by id.
fetched = await service.get_run("bob", "run-1")
assert fetched is not None and fetched["id"] == "run-1"
assert fetched["can_edit"] is False
# Bob can read subscribed records, but cannot delete Alice's record.
with pytest.raises(PermissionError):
await service.delete_run("bob", "run-1")
assert await store.get_run("run-1", "alice") is not None
# Carol (no subscription) cannot.
assert await service.list_runs("carol", "t1", limit=10) == []
@@ -0,0 +1,148 @@
"""Tests for the username-based login domain handling.
Verifies that:
- The default mail domain is ``cm.com`` (with env-var override still honoured).
- ``_normalize_username_local_part`` distinguishes raw-email vs username input.
- The login flow falls back to a legacy-domain lookup so the same username
keeps mapping to the same account after the default domain changes.
"""
from __future__ import annotations
import os
from datetime import UTC, datetime
from uuid import UUID, uuid4
import pytest
from app.gateway.auth.models import User
from app.gateway.auth.repositories.base import UserRepository
from app.gateway.routers.auth import (
_normalize_username_local_part,
_username_to_email,
)
class FakeUserRepository(UserRepository):
"""In-memory repo just enough to exercise the local-part lookup."""
def __init__(self) -> None:
self.rows: dict[str, User] = {}
self.create_calls: list[User] = []
async def create_user(self, user: User) -> User:
for existing in self.rows.values():
if existing.email == user.email:
raise ValueError(f"Email already registered: {user.email}")
u = User(
id=user.id or uuid4(),
email=user.email,
password_hash=user.password_hash,
system_role=user.system_role,
created_at=user.created_at or datetime.now(UTC),
oauth_provider=user.oauth_provider,
oauth_id=user.oauth_id,
needs_setup=user.needs_setup,
token_version=user.token_version,
)
self.rows[str(u.id)] = u
self.create_calls.append(u)
return u
async def get_user_by_id(self, user_id: str) -> User | None:
return self.rows.get(user_id)
async def get_user_by_email(self, email: str) -> User | None:
for user in self.rows.values():
if user.email == email:
return user
return None
async def find_user_by_email_local_part(self, local_part: str) -> User | None:
prefix = f"{(local_part or '').strip().lower()}@"
candidates = [u for u in self.rows.values() if u.email.lower().startswith(prefix)]
candidates.sort(key=lambda u: u.created_at)
return candidates[0] if candidates else None
async def update_user(self, user: User) -> User:
self.rows[str(user.id)] = user
return user
async def count_users(self) -> int:
return len(self.rows)
async def count_admin_users(self) -> int:
return sum(1 for u in self.rows.values() if u.system_role == "admin")
async def get_user_by_oauth(self, provider: str, oauth_id: str) -> User | None:
return None
def _seed(repo: FakeUserRepository, email: str, *, role: str = "user") -> User:
user = User(
id=uuid4(),
email=email,
password_hash=None,
system_role=role, # type: ignore[arg-type]
created_at=datetime.now(UTC),
needs_setup=False,
)
repo.rows[str(user.id)] = user
return user
def test_normalize_returns_none_for_explicit_email() -> None:
assert _normalize_username_local_part("alice@old.com") is None
def test_normalize_lowercases_and_keeps_safe_chars() -> None:
assert _normalize_username_local_part("Alice_Smith.1") == "alice_smith.1"
def test_normalize_strips_unsafe_chars() -> None:
assert _normalize_username_local_part("hi there!") == "hi-there"
def test_username_to_email_uses_cm_com_by_default(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("DEER_FLOW_USERNAME_LOGIN_DOMAIN", raising=False)
assert _username_to_email("alice") == "alice@cm.com"
def test_username_to_email_honours_env_override(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("DEER_FLOW_USERNAME_LOGIN_DOMAIN", "example.org")
assert _username_to_email("alice") == "alice@example.org"
def test_username_to_email_passes_explicit_email_through() -> None:
assert _username_to_email("bob@legacy.io") == "bob@legacy.io"
@pytest.mark.asyncio
async def test_local_part_lookup_finds_legacy_domain_user() -> None:
"""Same username under legacy domain should match the new lookup."""
repo = FakeUserRepository()
_seed(repo, "alice@local.deerflow")
legacy = await repo.find_user_by_email_local_part("alice")
assert legacy is not None
assert legacy.email == "alice@local.deerflow"
@pytest.mark.asyncio
async def test_local_part_lookup_returns_oldest_when_collision() -> None:
"""When the same local part exists under multiple domains, prefer the
oldest row so re-logins land on the original account."""
repo = FakeUserRepository()
older = _seed(repo, "alice@local.deerflow")
older.created_at = datetime(2024, 1, 1, tzinfo=UTC)
newer = _seed(repo, "alice@cm.com")
newer.created_at = datetime(2026, 1, 1, tzinfo=UTC)
match = await repo.find_user_by_email_local_part("alice")
assert match is not None
assert match.email == "alice@local.deerflow"
@pytest.mark.asyncio
async def test_local_part_lookup_misses_unknown_username() -> None:
repo = FakeUserRepository()
_seed(repo, "alice@cm.com")
assert await repo.find_user_by_email_local_part("bob") is None