From 3ea33e1deec83b7a178b9b4380dcb6a768b0975e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Thu, 14 May 2026 19:12:14 +0800 Subject: [PATCH 1/7] =?UTF-8?q?=E5=A4=A7=E6=A8=A1=E5=9E=8B=E9=80=9F?= =?UTF-8?q?=E5=BA=A6=E5=A2=9E=E5=8A=A0=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/workspace/input-box.tsx | 39 ++ .../workspace/messages/message-list.tsx | 7 +- .../messages/message-token-usage.tsx | 126 ++++- .../workspace/messages/use-message-metrics.ts | 235 +++++++++ .../settings/account-settings-page.tsx | 126 +++++ .../settings/appearance-settings-page.tsx | 27 ++ .../settings/mcp-server-edit-dialog.tsx | 362 ++++++++++++++ .../workspace/settings/settings-dialog.tsx | 9 + .../workspace/settings/tool-settings-page.tsx | 207 +++++++- .../workspace/workspace-nav-menu.tsx | 29 +- frontend-web/src/core/llm-metrics/api.ts | 98 ++++ frontend-web/src/core/llm-metrics/hooks.ts | 12 + frontend-web/src/core/mcp/hooks.ts | 42 ++ frontend-web/src/core/mcp/types.ts | 29 +- .../src/core/scheduled-tasks/markdown-docx.ts | 381 ++++++++++++--- .../src/core/scheduled-tasks/types.ts | 4 + frontend-web/src/core/settings/local.ts | 15 + frontend-web/src/core/system-settings/api.ts | 44 ++ .../src/core/system-settings/hooks.ts | 29 ++ frontend-web/src/pages/ChatPage.tsx | 35 +- frontend-web/src/pages/LlmMetricsPage.tsx | 458 ++++++++++++++++++ .../src/pages/PromptPrefixSettingsPage.tsx | 125 +++++ .../src/pages/ScheduledTaskRunDetailPage.tsx | 209 +++++++- frontend-web/src/pages/ScheduledTasksPage.tsx | 132 ++++- frontend-web/src/pages/WorkspaceRoutes.tsx | 26 + .../backend/app/gateway/app.py | 8 + .../app/gateway/auth/local_provider.py | 9 + .../app/gateway/auth/repositories/base.py | 20 + .../app/gateway/auth/repositories/sqlite.py | 21 + .../backend/app/gateway/deps.py | 9 + .../backend/app/gateway/routers/auth.py | 53 +- .../app/gateway/routers/llm_metrics.py | 323 ++++++++++++ .../backend/app/gateway/routers/mcp.py | 40 +- .../app/gateway/routers/scheduled_tasks.py | 72 ++- .../app/gateway/routers/system_settings.py | 65 +++ .../backend/app/gateway/services.py | 53 ++ .../middlewares/llm_metrics_middleware.py | 335 +++++++++++++ .../tool_error_handling_middleware.py | 8 + .../deerflow/config/extensions_config.py | 7 +- .../deerflow/config/system_settings.py | 77 +++ .../packages/harness/deerflow/mcp/tools.py | 19 + .../persistence/llm_metrics/__init__.py | 25 + .../deerflow/persistence/llm_metrics/base.py | 66 +++ .../deerflow/persistence/llm_metrics/model.py | 62 +++ .../deerflow/persistence/llm_metrics/sql.py | 109 +++++ .../versions/20260514_02_llm_call_metrics.py | 59 +++ .../deerflow/persistence/models/__init__.py | 4 + .../deerflow/runtime/scheduler/service.py | 12 +- .../backend/tests/test_llm_metrics.py | 321 ++++++++++++ .../backend/tests/test_prompt_prefix.py | 108 +++++ .../tests/test_scheduled_tasks_publish.py | 10 + .../tests/test_username_login_domain.py | 148 ++++++ 52 files changed, 4673 insertions(+), 176 deletions(-) create mode 100644 frontend-web/src/components/workspace/messages/use-message-metrics.ts create mode 100644 frontend-web/src/components/workspace/settings/account-settings-page.tsx create mode 100644 frontend-web/src/components/workspace/settings/mcp-server-edit-dialog.tsx create mode 100644 frontend-web/src/core/llm-metrics/api.ts create mode 100644 frontend-web/src/core/llm-metrics/hooks.ts create mode 100644 frontend-web/src/core/system-settings/api.ts create mode 100644 frontend-web/src/core/system-settings/hooks.ts create mode 100644 frontend-web/src/pages/LlmMetricsPage.tsx create mode 100644 frontend-web/src/pages/PromptPrefixSettingsPage.tsx create mode 100644 offline-backend-20260512/backend/app/gateway/routers/llm_metrics.py create mode 100644 offline-backend-20260512/backend/app/gateway/routers/system_settings.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/llm_metrics_middleware.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/config/system_settings.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/llm_metrics/__init__.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/llm_metrics/base.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/llm_metrics/model.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/llm_metrics/sql.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260514_02_llm_call_metrics.py create mode 100644 offline-backend-20260512/backend/tests/test_llm_metrics.py create mode 100644 offline-backend-20260512/backend/tests/test_prompt_prefix.py create mode 100644 offline-backend-20260512/backend/tests/test_username_login_domain.py diff --git a/frontend-web/src/components/workspace/input-box.tsx b/frontend-web/src/components/workspace/input-box.tsx index 8ae851ac..628ecfb7 100644 --- a/frontend-web/src/components/workspace/input-box.tsx +++ b/frontend-web/src/components/workspace/input-box.tsx @@ -117,6 +117,7 @@ export function InputBox({ onFollowupsVisibilityChange, onSubmit, onStop, + promptPrefix, ...props }: Omit, "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({ )} + {promptPrefix && ( + + + + {promptPrefix.label} + + {promptPrefix.on && ( + + )} + + + )} + {groupMessages(messages, (group) => { if (group.type === "human" || group.type === "assistant") { return group.messages.map((msg) => { @@ -265,6 +269,7 @@ export function MessageList({ })} {thread.isLoading && }
+ ); diff --git a/frontend-web/src/components/workspace/messages/message-token-usage.tsx b/frontend-web/src/components/workspace/messages/message-token-usage.tsx index 8c4cabf3..094a2938 100644 --- a/frontend-web/src/components/workspace/messages/message-token-usage.tsx +++ b/frontend-web/src/components/workspace/messages/message-token-usage.tsx @@ -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 ( + + + + {prefix} + {display} + + tok/s + + + + + ); +} + 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 (
- - - {t.tokenUsage.label} - - {usage ? ( + {showUsage && usage ? ( <> + + + {t.tokenUsage.label} + {t.tokenUsage.input}: {formatTokenCount(usage.inputTokens)} @@ -47,9 +117,17 @@ export function MessageTokenUsage({ {t.tokenUsage.total}: {formatTokenCount(usage.totalTokens)} - ) : ( - {t.tokenUsage.unavailableShort} - )} + ) : null} + {showSpeed && resolvedMetric ? ( + <> + + {resolvedMetric.durationMs > 0 && ( + + {formatDuration(resolvedMetric.durationMs)} + + )} + + ) : null}
); } @@ -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 ( + + {children} + + ); +} diff --git a/frontend-web/src/components/workspace/messages/use-message-metrics.ts b/frontend-web/src/components/workspace/messages/use-message-metrics.ts new file mode 100644 index 00000000..42224fbe --- /dev/null +++ b/frontend-web/src/components/workspace/messages/use-message-metrics.ts @@ -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 { + const timingRef = useRef>(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(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(); + 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 = 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>( + EMPTY_METRICS, +); + +export function useMessageMetric(messageId: string | undefined): MessageMetric | undefined { + const metrics = useContext(MessageMetricsContext); + return messageId ? metrics.get(messageId) : undefined; +} diff --git a/frontend-web/src/components/workspace/settings/account-settings-page.tsx b/frontend-web/src/components/workspace/settings/account-settings-page.tsx new file mode 100644 index 00000000..864e4258 --- /dev/null +++ b/frontend-web/src/components/workspace/settings/account-settings-page.tsx @@ -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 ( + +
+ 没有可显示的账号信息。 +
+
+ ); + } + + 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 ( + +
+
+
+ +
+
+
{displayName}
+
+ {roleLabel(auth.system_role)} +
+
+ + {roleLabel(auth.system_role)} + +
+ + copy(displayName, "用户名已复制")} + /> + copy(auth.user_id, "用户 ID 已复制")} + /> + + + +
+ +
+
+
+ ); +} + +function InfoRow({ + label, + value, + onCopy, +}: { + label: string; + value: string; + onCopy?: () => void; +}) { + return ( +
+
{label}
+
{value || "—"}
+ {onCopy && value && ( + + )} +
+ ); +} diff --git a/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx b/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx index d304931a..cd1feb3b 100644 --- a/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx +++ b/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx @@ -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() { + + + + +
+
+
回答速度指示器
+

+ 在生成回答时显示一个小胶囊,实时估算当前 token/s + 并用绿到红的渐变条直观反映快慢。默认关闭。 +

+
+ + setSettings("display", { streamSpeedMeterEnabled: checked }) + } + aria-label="回答速度指示器" + /> +
+
); } diff --git a/frontend-web/src/components/workspace/settings/mcp-server-edit-dialog.tsx b/frontend-web/src/components/workspace/settings/mcp-server-edit-dialog.tsx new file mode 100644 index 00000000..a359daa4 --- /dev/null +++ b/frontend-web/src/components/workspace/settings/mcp-server-edit-dialog.tsx @@ -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 { + if (!map) return ""; + return Object.entries(map) + .map(([k, v]) => `${k}=${v}`) + .join("\n"); +} + +function textToKV(text: string): { value: Record; error?: string } { + const result: Record = {}; + 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("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 ( + + + + + {mode === "create" ? "新增 MCP 服务器" : `编辑 MCP 服务器`} + + +
+ + setName(e.target.value)} + placeholder="例如 github、filesystem" + aria-invalid={nameConflict || undefined} + /> + {nameConflict && ( +
已存在同名服务器
+ )} +
+ +
+ + + + +
+ + + {enabled ? "已启用" : "已停用"} + +
+
+
+ + +