大模型速度增加提示
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user