This commit is contained in:
xuhd committed 2026-05-15 15:26:16 +08:00
commit 1574d44cc3
80 files changed
+9366 -1299

No files matched your search

@@ -16,7 +16,7 @@ export function AgentGallery() {
const { t } = useI18n();
const { agents, isLoading } = useAgents();
const router = useRouter();
const [activeTab, setActiveTab] = useState<"all" | "mine">("all");
const [activeTab, setActiveTab] = useState<"all" | "mine">("mine");
const storedAuth = useMemo(() => getStoredAuth(), []);
const currentUserId = storedAuth?.user_id ?? null;
@@ -84,8 +84,8 @@ export function AgentGallery() {
className="flex flex-1 flex-col overflow-hidden"
>
<TabsList className="mx-6 mt-4 w-auto self-start">
<TabsTrigger value="all">全部</TabsTrigger>
<TabsTrigger value="mine">自己的</TabsTrigger>
<TabsTrigger value="mine">我的</TabsTrigger>
<TabsTrigger value="all">广场</TabsTrigger>
</TabsList>
<TabsContent value="all" className="flex-1 overflow-y-auto p-6 mt-0">
@@ -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;
}
@@ -1,212 +0,0 @@
"use client";
import { BellIcon, CheckCheckIcon, Loader2, Trash2Icon } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
SidebarMenuButton,
useSidebar,
} from "@/components/ui/sidebar";
import { useI18n } from "@/core/i18n/hooks";
import {
useDeleteNotification,
useMarkAllNotificationsRead,
useMarkNotificationRead,
useNotifications,
useUnreadCount,
} from "@/core/notifications";
import type { NotificationItem } from "@/core/notifications";
import { formatTimeAgo } from "@/core/utils/datetime";
import { cn } from "@/lib/utils";
const BELL_LIMIT = 20;
export function NotificationBell() {
const { t } = useI18n();
const { open: isSidebarOpen } = useSidebar();
const [open, setOpen] = useState(false);
// Render badge from the lightweight count endpoint so the bell stays cheap
// when nobody opens it. Only when the user actually opens the dropdown do
// we pay for the full list query.
const { data: unreadCount = 0 } = useUnreadCount();
const {
data: notifications,
isLoading,
isFetching,
} = useNotifications({ limit: BELL_LIMIT });
const markRead = useMarkNotificationRead();
const markAllRead = useMarkAllNotificationsRead();
const removeNotification = useDeleteNotification();
const hasUnread = unreadCount > 0;
const items = notifications ?? [];
async function handleClickItem(notification: NotificationItem) {
if (notification.read) return;
try {
await markRead.mutateAsync(notification.id);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleMarkAllRead() {
if (!hasUnread) return;
try {
await markAllRead.mutateAsync();
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleDelete(notification: NotificationItem) {
try {
await removeNotification.mutateAsync(notification.id);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
aria-label={t.notifications.bellAria}
>
{isSidebarOpen ? (
<div className="text-sidebar-foreground/85 flex w-full items-center gap-2 text-left text-sm">
<div className="relative">
<BellIcon className="size-4" />
{hasUnread && (
<span className="absolute -right-1 -top-1 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-medium leading-none text-white">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</div>
<span>{t.notifications.title}</span>
{hasUnread && (
<span className="text-muted-foreground ml-auto text-xs">
{t.notifications.unreadShort.replace("{count}", String(unreadCount))}
</span>
)}
</div>
) : (
<div className="flex size-full items-center justify-center">
<div className="relative">
<BellIcon className="text-sidebar-foreground/80 size-4" />
{hasUnread && (
<span className="absolute -right-1 -top-1 size-2 rounded-full bg-red-500" />
)}
</div>
</div>
)}
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
side="right"
sideOffset={8}
className="w-96 p-0"
>
<div className="flex items-center justify-between gap-2 border-b px-3 py-2">
<div className="flex items-center gap-2">
<BellIcon className="size-4" />
<span className="text-sm font-semibold">
{t.notifications.title}
</span>
{isFetching && !isLoading && (
<Loader2 className="text-muted-foreground size-3 animate-spin" />
)}
</div>
<Button
size="sm"
variant="ghost"
className="h-7 px-2 text-xs"
disabled={!hasUnread || markAllRead.isPending}
onClick={() => void handleMarkAllRead()}
>
<CheckCheckIcon className="mr-1 size-3.5" />
{t.notifications.markAllRead}
</Button>
</div>
<ScrollArea className="max-h-[60vh]">
{isLoading ? (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-8 text-sm">
<Loader2 className="size-4 animate-spin" />
{t.common.loading}
</div>
) : items.length === 0 ? (
<div className="text-muted-foreground py-10 text-center text-sm">
{t.notifications.empty}
</div>
) : (
<ul className="divide-y">
{items.map((notification) => (
<li
key={notification.id}
className={cn(
"group cursor-pointer px-3 py-2 transition-colors hover:bg-accent/40",
!notification.read && "bg-blue-500/5",
)}
onClick={() => void handleClickItem(notification)}
>
<div className="flex items-start gap-2">
{!notification.read && (
<span className="mt-1.5 size-2 shrink-0 rounded-full bg-blue-500" />
)}
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<p
className={cn(
"truncate text-sm",
!notification.read && "font-medium",
)}
title={notification.title}
>
{notification.title || notification.type}
</p>
{notification.created_at && (
<span className="text-muted-foreground shrink-0 text-[10px]">
{formatTimeAgo(notification.created_at)}
</span>
)}
</div>
{notification.body && (
<p className="text-muted-foreground line-clamp-2 text-xs">
{notification.body}
</p>
)}
</div>
<Button
size="icon"
variant="ghost"
className="text-muted-foreground hover:text-destructive size-6 shrink-0 opacity-0 group-hover:opacity-100"
onClick={(event) => {
event.stopPropagation();
void handleDelete(notification);
}}
aria-label={t.notifications.deleteAria}
>
<Trash2Icon className="size-3" />
</Button>
</div>
</li>
))}
</ul>
)}
</ScrollArea>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,329 @@
"use client";
import {
BellIcon,
CheckCheckIcon,
ChevronLeftIcon,
Loader2,
SettingsIcon,
Trash2Icon,
XIcon,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogTitle,
} from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Switch } from "@/components/ui/switch";
import { useI18n } from "@/core/i18n/hooks";
import { useNotification } from "@/core/notification/hooks";
import {
useDeleteNotification,
useMarkAllNotificationsRead,
useMarkNotificationRead,
useNotifications,
useUnreadCount,
} from "@/core/notifications";
import type { NotificationItem } from "@/core/notifications";
import { useLocalSettings } from "@/core/settings";
import { formatTimeAgo } from "@/core/utils/datetime";
import { cn } from "@/lib/utils";
const BELL_LIMIT = 20;
type NotificationView = "list" | "settings";
export function NotificationDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { t } = useI18n();
const [view, setView] = useState<NotificationView>("list");
const {
data: notifications,
isLoading,
isFetching,
} = useNotifications({ limit: BELL_LIMIT });
const { data: unreadCount = 0 } = useUnreadCount();
const markRead = useMarkNotificationRead();
const markAllRead = useMarkAllNotificationsRead();
const removeNotification = useDeleteNotification();
const hasUnread = unreadCount > 0;
const items = notifications ?? [];
function handleOpenChange(next: boolean) {
onOpenChange(next);
// 关闭弹窗时回到列表视图,下次打开默认看到通知列表。
if (!next) setView("list");
}
async function handleClickItem(notification: NotificationItem) {
if (notification.read) return;
try {
await markRead.mutateAsync(notification.id);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleMarkAllRead() {
if (!hasUnread) return;
try {
await markAllRead.mutateAsync();
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleDelete(notification: NotificationItem) {
try {
await removeNotification.mutateAsync(notification.id);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent
showCloseButton={false}
className="flex max-h-[80vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-xl"
aria-describedby={undefined}
>
{view === "list" ? (
<>
<div className="flex items-center justify-between gap-2 border-b px-3 py-3">
<div className="flex items-center gap-2">
<BellIcon className="size-4" />
<DialogTitle className="text-base font-semibold">
{t.notifications.title}
</DialogTitle>
{isFetching && !isLoading && (
<Loader2 className="text-muted-foreground size-3 animate-spin" />
)}
</div>
<div className="flex items-center gap-1">
<Button
size="sm"
variant="ghost"
className="h-7 px-2 text-xs"
disabled={!hasUnread || markAllRead.isPending}
onClick={() => void handleMarkAllRead()}
>
<CheckCheckIcon className="mr-1 size-3.5" />
{t.notifications.markAllRead}
</Button>
<Button
size="icon"
variant="ghost"
className="text-muted-foreground size-7"
aria-label={t.notifications.settingsAria}
onClick={() => setView("settings")}
>
<SettingsIcon className="size-4" />
</Button>
<DialogClose asChild>
<Button
size="icon"
variant="ghost"
className="text-muted-foreground size-7"
aria-label={t.common.close}
>
<XIcon className="size-4" />
</Button>
</DialogClose>
</div>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="w-full px-3 py-3">
{isLoading ? (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-20 text-sm">
<Loader2 className="size-4 animate-spin" />
{t.common.loading}
</div>
) : items.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 py-24 text-center">
<div className="bg-muted flex size-14 items-center justify-center rounded-full">
<BellIcon className="text-muted-foreground size-6" />
</div>
<p className="text-muted-foreground text-sm">
{t.notifications.empty}
</p>
</div>
) : (
<ul className="flex flex-col gap-2">
{items.map((notification) => (
<li
key={notification.id}
className={cn(
"group cursor-pointer rounded-lg border px-4 py-3 transition-colors hover:bg-accent/50",
!notification.read
? "border-blue-500/30 bg-blue-500/5"
: "border-border",
)}
onClick={() => void handleClickItem(notification)}
>
<div className="flex items-start gap-3">
<span
className={cn(
"mt-1.5 size-2 shrink-0 rounded-full",
notification.read ? "bg-transparent" : "bg-blue-500",
)}
/>
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-3">
<p
className={cn(
"truncate text-sm",
notification.read
? "font-medium"
: "font-semibold",
)}
title={notification.title}
>
{notification.title || notification.type}
</p>
{notification.created_at && (
<span className="text-muted-foreground shrink-0 text-xs">
{formatTimeAgo(notification.created_at)}
</span>
)}
</div>
{notification.body && (
<p className="text-muted-foreground mt-1 line-clamp-2 text-xs leading-relaxed">
{notification.body}
</p>
)}
</div>
<Button
size="icon"
variant="ghost"
className="text-muted-foreground hover:text-destructive size-7 shrink-0 opacity-0 transition-opacity group-hover:opacity-100"
onClick={(event) => {
event.stopPropagation();
void handleDelete(notification);
}}
aria-label={t.notifications.deleteAria}
>
<Trash2Icon className="size-3.5" />
</Button>
</div>
</li>
))}
</ul>
)}
</div>
</ScrollArea>
</>
) : (
<NotificationSettingsView onBack={() => setView("list")} />
)}
</DialogContent>
</Dialog>
);
}
function NotificationSettingsView({ onBack }: { onBack: () => void }) {
const { t } = useI18n();
const { permission, isSupported, requestPermission, showNotification } =
useNotification();
const [settings, setSettings] = useLocalSettings();
const handleRequestPermission = async () => {
await requestPermission();
};
const handleTestNotification = () => {
showNotification(t.settings.notification.testTitle, {
body: t.settings.notification.testBody,
});
};
const handleEnableNotification = (enabled: boolean) => {
setSettings("notification", { enabled });
};
return (
<>
<div className="flex items-center justify-between gap-2 border-b px-3 py-3">
<div className="flex items-center gap-2">
<Button
size="icon"
variant="ghost"
className="text-muted-foreground size-7"
aria-label={t.notifications.backAria}
onClick={onBack}
>
<ChevronLeftIcon className="size-4" />
</Button>
<DialogTitle className="text-base font-semibold">
{t.notifications.settingsTitle}
</DialogTitle>
</div>
<DialogClose asChild>
<Button
size="icon"
variant="ghost"
className="text-muted-foreground size-7"
aria-label={t.common.close}
>
<XIcon className="size-4" />
</Button>
</DialogClose>
</div>
<div className="mx-auto flex w-full max-w-md flex-col gap-4 px-3 py-6">
{!isSupported ? (
<p className="text-muted-foreground text-sm">
{t.settings.notification.notSupported}
</p>
) : (
<>
<div className="flex items-start justify-between gap-3">
<p className="text-muted-foreground text-sm">
{t.settings.notification.description}
</p>
<Switch
disabled={permission !== "granted"}
checked={
permission === "granted" && settings.notification.enabled
}
onCheckedChange={handleEnableNotification}
/>
</div>
{permission === "default" && (
<Button onClick={handleRequestPermission} variant="default">
<BellIcon className="mr-2 size-4" />
{t.settings.notification.requestPermission}
</Button>
)}
{permission === "denied" && (
<p className="text-muted-foreground rounded-md border border-amber-200 bg-amber-50 p-3 text-sm dark:border-amber-800 dark:bg-amber-950/50">
{t.settings.notification.deniedHint}
</p>
)}
{permission === "granted" && settings.notification.enabled && (
<Button onClick={handleTestNotification} variant="outline">
<BellIcon className="mr-2 size-4" />
{t.settings.notification.testButton}
</Button>
)}
</>
)}
</div>
</>
);
}
@@ -173,91 +173,89 @@ export function RecentChatList() {
<div className="flex w-full flex-col gap-1">
{visibleThreads.map((thread) => {
const isActive = pathOfThread(thread) === pathname;
return (
<SidebarMenuItem
key={thread.thread_id}
className="group/side-menu-item"
>
<SidebarMenuButton isActive={isActive} asChild>
<div>
<Link
className="text-muted-foreground block w-full whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
href={pathOfThread(thread)}
>
{titleOfThread(thread)}
</Link>
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction
showOnHover
className="bg-background/50 hover:bg-background"
>
<MoreHorizontal />
<span className="sr-only">{t.common.more}</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-48 rounded-lg"
side={"right"}
align={"start"}
>
<DropdownMenuItem
onSelect={() =>
handleRenameClick(
thread.thread_id,
titleOfThread(thread),
)
}
>
<Pencil className="text-muted-foreground" />
<span>{t.common.rename}</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => handleShare(thread)}
>
<Share2 className="text-muted-foreground" />
<span>{t.common.share}</span>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Download className="text-muted-foreground" />
<span>{t.common.export}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem
onSelect={() =>
handleExport(thread, "markdown")
}
>
<FileText className="text-muted-foreground" />
<span>{t.common.exportAsMarkdown}</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() =>
handleExport(thread, "json")
}
>
<FileJson className="text-muted-foreground" />
<span>{t.common.exportAsJSON}</span>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => handleDelete(thread.thread_id)}
>
<Trash2 className="text-muted-foreground" />
<span>{t.common.delete}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
return (
<SidebarMenuItem
key={thread.thread_id}
className="group/side-menu-item"
>
<SidebarMenuButton isActive={isActive} asChild>
<Link
className="text-muted-foreground block w-full whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
href={pathOfThread(thread)}
>
{titleOfThread(thread)}
</Link>
</SidebarMenuButton>
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction
showOnHover
className="bg-background/50 hover:bg-background"
>
<MoreHorizontal />
<span className="sr-only">{t.common.more}</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-48 rounded-lg"
side={"right"}
align={"start"}
>
<DropdownMenuItem
onSelect={() =>
handleRenameClick(
thread.thread_id,
titleOfThread(thread),
)
}
>
<Pencil className="text-muted-foreground" />
<span>{t.common.rename}</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => handleShare(thread)}
>
<Share2 className="text-muted-foreground" />
<span>{t.common.share}</span>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Download className="text-muted-foreground" />
<span>{t.common.export}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem
onSelect={() =>
handleExport(thread, "markdown")
}
>
<FileText className="text-muted-foreground" />
<span>{t.common.exportAsMarkdown}</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() =>
handleExport(thread, "json")
}
>
<FileJson className="text-muted-foreground" />
<span>{t.common.exportAsJSON}</span>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => handleDelete(thread.thread_id)}
>
<Trash2 className="text-muted-foreground" />
<span>{t.common.delete}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</SidebarMenuItem>
);
})}
</div>
</SidebarMenu>
</SidebarGroupContent>
@@ -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>
);
}
@@ -1,92 +0,0 @@
"use client";
import { BellIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { useI18n } from "@/core/i18n/hooks";
import { useNotification } from "@/core/notification/hooks";
import { useLocalSettings } from "@/core/settings";
import { SettingsSection } from "./settings-section";
export function NotificationSettingsPage() {
const { t } = useI18n();
const { permission, isSupported, requestPermission, showNotification } =
useNotification();
const [settings, setSettings] = useLocalSettings();
const handleRequestPermission = async () => {
await requestPermission();
};
const handleTestNotification = () => {
showNotification(t.settings.notification.testTitle, {
body: t.settings.notification.testBody,
});
};
const handleEnableNotification = async (enabled: boolean) => {
setSettings("notification", {
enabled,
});
};
if (!isSupported) {
return (
<SettingsSection
title={t.settings.notification.title}
description={t.settings.notification.description}
>
<p className="text-muted-foreground text-sm">
{t.settings.notification.notSupported}
</p>
</SettingsSection>
);
}
return (
<SettingsSection
title={t.settings.notification.title}
description={
<div className="flex items-center gap-2">
<div>{t.settings.notification.description}</div>
<div>
<Switch
disabled={permission !== "granted"}
checked={
permission === "granted" && settings.notification.enabled
}
onCheckedChange={handleEnableNotification}
/>
</div>
</div>
}
>
<div className="flex flex-col gap-4">
{permission === "default" && (
<Button onClick={handleRequestPermission} variant="default">
<BellIcon className="mr-2 size-4" />
{t.settings.notification.requestPermission}
</Button>
)}
{permission === "denied" && (
<p className="text-muted-foreground rounded-md border border-amber-200 bg-amber-50 p-3 text-sm dark:border-amber-800 dark:bg-amber-950/50">
{t.settings.notification.deniedHint}
</p>
)}
{permission === "granted" && settings.notification.enabled && (
<div className="flex flex-col gap-4">
<Button onClick={handleTestNotification} variant="outline">
<BellIcon className="mr-2 size-4" />
{t.settings.notification.testButton}
</Button>
</div>
)}
</div>
</SettingsSection>
);
}
@@ -1,12 +1,6 @@
"use client";
import {
BellIcon,
BrainIcon,
PaletteIcon,
SparklesIcon,
WrenchIcon,
} from "lucide-react";
import { BellIcon, BrainIcon, PaletteIcon, SparklesIcon, UserIcon, WrenchIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import {
@@ -17,20 +11,18 @@ 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";
import { SkillSettingsPage } from "@/components/workspace/settings/skill-settings-page";
import { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-page";
import { useI18n } from "@/core/i18n/hooks";
import { cn } from "@/lib/utils";
type SettingsSection =
| "account"
| "appearance"
| "memory"
| "tools"
| "skills"
| "notification"
| "about";
type SettingsDialogProps = React.ComponentProps<typeof Dialog> & {
@@ -70,14 +62,18 @@ export function SettingsDialog(props: SettingsDialogProps) {
label: t.settings.sections.memory,
icon: BrainIcon,
},
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
{
id: "account",
label: "个人信息",
icon: UserIcon,
},
// { id: "about", label: t.settings.sections.about, icon: InfoIcon },
],
[
t.settings.sections.appearance,
t.settings.sections.memory,
t.settings.sections.tools,
t.settings.sections.skills,
t.settings.sections.notification,
t.settings.sections.about,
],
);
@@ -123,11 +119,10 @@ 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 />}
{activeSection === "notification" && <NotificationSettingsPage />}
{activeSection === "skills" && <SkillSettingsPage />}
{activeSection === "about" && <AboutSettingsPage />}
</div>
</ScrollArea>
@@ -54,6 +54,7 @@ import {
useInstallSkillUpload,
useSetSkillPinned,
useSetSkillPublished,
useSkillContent,
useSkills,
useValidateSkillUpload,
} from "@/core/skills/hooks";
@@ -92,7 +93,7 @@ function SkillSettingsList({
}) {
const { t } = useI18n();
const router = useRouter();
const [filter, setFilter] = useState<string>("all");
const [filter, setFilter] = useState<string>("mine");
const [installOpen, setInstallOpen] = useState(false);
const [staleSheetOpen, setStaleSheetOpen] = useState(false);
const [archiveSheetOpen, setArchiveSheetOpen] = useState(false);
@@ -101,9 +102,9 @@ function SkillSettingsList({
const currentUserId = storedAuth?.user_id ?? null;
const isAdmin = storedAuth?.system_role === "admin";
// Visible-to-me skills, classified into the three buckets the UI cares about.
// "All" = built-in + anyone-published-custom + mine (active state only).
// "Mine" = strictly my own custom skills (active state only).
// 把可见技能分类成 UI 关心的几个桶。
// "All" = 内置 + 已发布的技能(仅 active 状态);自己的技能也要自己发布了才出现。
// "Mine" = 严格属于我自己的自定义技能(仅 active 状态)。
const { activeAll, activeMine, staleVisible, archivedVisible } = useMemo(() => {
const all: Skill[] = [];
const mine: Skill[] = [];
@@ -115,8 +116,6 @@ function SkillSettingsList({
const isOwnedByMe =
isCustom && !!skill.owner_user_id && skill.owner_user_id === currentUserId;
const isBuiltin = skill.builtin ?? !skill.owner_user_id;
const isPublishedByOthers =
isCustom && !isOwnedByMe && !isBuiltin && !!skill.published;
if (state === "stale") {
stale.push(skill);
@@ -127,7 +126,7 @@ function SkillSettingsList({
continue;
}
// state === active
if (!isCustom || isBuiltin || isOwnedByMe || isPublishedByOthers) {
if (isBuiltin || !!skill.published) {
all.push(skill);
}
if (isOwnedByMe) {
@@ -156,8 +155,8 @@ function SkillSettingsList({
<div className="flex flex-wrap items-center gap-2">
<Tabs value={filter} onValueChange={setFilter}>
<TabsList variant="line">
<TabsTrigger value="all">{t.settings.skills.tabAll}</TabsTrigger>
<TabsTrigger value="mine">{t.settings.skills.tabMine}</TabsTrigger>
<TabsTrigger value="all">{t.settings.skills.tabAll}</TabsTrigger>
</TabsList>
</Tabs>
<Button
@@ -258,6 +257,7 @@ function SkillRow({
const setPinned = useSetSkillPinned();
const deleteSkill = useDeleteCustomSkill();
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
const [detailOpen, setDetailOpen] = useState(false);
const isCustom = skill.category === "custom";
// Built-in flag falls back to "no DB owner" so legacy responses without
@@ -308,7 +308,10 @@ function SkillRow({
return (
<>
<Item className="w-full" variant="outline">
<ItemContent>
<ItemContent
className="cursor-pointer"
onClick={() => setDetailOpen(true)}
>
<ItemTitle>
<div className="flex flex-wrap items-center gap-2">
<span>{skill.name}</span>
@@ -473,10 +476,55 @@ function SkillRow({
</DialogFooter>
</DialogContent>
</Dialog>
<SkillDetailDialog
skill={skill}
open={detailOpen}
onOpenChange={setDetailOpen}
/>
</>
);
}
function SkillDetailDialog({
skill,
open,
onOpenChange,
}: {
skill: Skill;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { t } = useI18n();
const { data, isLoading, error } = useSkillContent(open ? skill.name : null);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[80vh] flex-col gap-3 sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{skill.name}</DialogTitle>
<DialogDescription>{skill.description}</DialogDescription>
</DialogHeader>
<div className="bg-muted/40 min-h-0 flex-1 overflow-auto rounded-md border">
{isLoading ? (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-16 text-sm">
<Loader2 className="size-4 animate-spin" />
{t.common.loading}
</div>
) : error ? (
<div className="text-destructive p-4 text-sm">
{error instanceof Error ? error.message : String(error)}
</div>
) : (
<pre className="text-foreground/90 whitespace-pre-wrap wrap-break-word p-4 font-mono text-xs leading-relaxed">
{data?.content}
</pre>
)}
</div>
</DialogContent>
</Dialog>
);
}
function EmptySkill({ onCreateSkill }: { onCreateSkill: () => void }) {
const { t } = useI18n();
return (
@@ -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,14 @@
"use client";
import { ChevronsUpDown, MessageSquarePlusIcon, Settings2Icon, SettingsIcon } from "lucide-react";
import {
BellIcon,
ChevronsUpDown,
GaugeIcon,
MessageSquareCodeIcon,
MessageSquarePlusIcon,
Settings2Icon,
SettingsIcon,
} from "lucide-react";
import { useEffect, useState } from "react";
import {
@@ -19,8 +27,9 @@ import {
} from "@/components/ui/sidebar";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import { useUnreadCount } from "@/core/notifications";
import { NotificationBell } from "./notification-bell";
import { NotificationDialog } from "./notification-dialog";
import { useNavigate } from "react-router-dom";
import { SettingsDialog } from "./settings";
@@ -50,11 +59,13 @@ export function WorkspaceNavMenu() {
const isAdmin = getStoredAuth()?.system_role === "admin";
const [settingsOpen, setSettingsOpen] = useState(false);
const [settingsDefaultSection, setSettingsDefaultSection] = useState<
"appearance" | "memory" | "tools" | "notification" | "about"
"appearance" | "memory" | "tools" | "about"
>("appearance");
const [notificationOpen, setNotificationOpen] = useState(false);
const [mounted, setMounted] = useState(false);
const { open: isSidebarOpen } = useSidebar();
const { t } = useI18n();
const { data: unreadCount = 0 } = useUnreadCount();
useEffect(() => {
setMounted(true);
@@ -67,10 +78,11 @@ export function WorkspaceNavMenu() {
onOpenChange={setSettingsOpen}
defaultSection={settingsDefaultSection}
/>
<NotificationDialog
open={notificationOpen}
onOpenChange={setNotificationOpen}
/>
<SidebarMenu className="w-full">
<SidebarMenuItem>
<NotificationBell />
</SidebarMenuItem>
<SidebarMenuItem>
{mounted ? (
<DropdownMenu>
@@ -88,6 +100,15 @@ export function WorkspaceNavMenu() {
sideOffset={4}
>
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => setNotificationOpen(true)}>
<BellIcon />
{t.notifications.title}
{unreadCount > 0 && (
<span className="ml-auto flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-medium leading-none text-white">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setSettingsDefaultSection("appearance");
@@ -107,6 +128,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>