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