Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into xuhd
This commit is contained in:
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>
|
||||
|
||||
Reference in new issue
Block a user