返回 DeepSeek-Reasonix
MCPInteractionCard.tsx
根目录 / desktop / frontend / src / components / MCPInteractionCard.tsx
1 import { useEffect, useMemo, useState } from "react";
2 import type { WireMCPInteraction } from "../lib/types";
3 import { useI18n } from "../lib/i18n";
4 import { structuredFieldIssue, type StructuredFieldValue } from "../lib/structuredFormSchema";
5 import { PromptBadge, PromptShelf } from "./PromptShelf";
6 import {
7 StructuredForm,
8 coerceStructuredValues,
9 initialStructuredValues,
10 parseStructuredSchema,
11 } from "./StructuredForm";
12 import "./MCPInteractionCard.css";
13
14 function hostOfURL(raw: string | undefined): string {
15 if (!raw) return "";
16 try {
17 return new URL(raw).host;
18 } catch {
19 return "";
20 }
21 }
22
23 // MCPInteractionCard renders one server-initiated elicitation. Missing or
24 // unknown mode falls back to form, preserving pre-2025-11-25 behavior.
25 export function MCPInteractionCard({
26 interaction,
27 instanceKey,
28 busy,
29 onAnswer,
30 onOpenLink,
31 }: {
32 interaction: WireMCPInteraction;
33 instanceKey: string;
34 busy: boolean;
35 onAnswer: (id: string, action: "accept" | "decline" | "cancel", content?: Record<string, unknown>) => void;
36 onOpenLink?: (url: string) => void;
37 }) {
38 const { t } = useI18n();
39 const mode = interaction.mode === "url" ? "url" : "form";
40 const schema = useMemo(() => parseStructuredSchema(interaction.requestedSchema), [interaction.requestedSchema]);
41 const [values, setValues] = useState<Record<string, StructuredFieldValue>>(() => initialStructuredValues(schema.fields));
42 const [openedLink, setOpenedLink] = useState(false);
43 const [showErrors, setShowErrors] = useState(false);
44 const [focusInvalidNonce, setFocusInvalidNonce] = useState(0);
45
46 useEffect(() => {
47 setValues(initialStructuredValues(schema.fields));
48 setOpenedLink(false);
49 setShowErrors(false);
50 }, [instanceKey, schema.fields]);
51
52 const firstIssue = schema.fields.find((field) => structuredFieldIssue(field, values[field.key]));
53 const formBlocked = schema.unsupported || firstIssue !== undefined;
54 const messageID = `mcp-interaction-${interaction.id}-message`;
55 const targetHost = hostOfURL(interaction.url);
56
57 const submit = () => {
58 if (formBlocked) {
59 setShowErrors(true);
60 setFocusInvalidNonce((nonce) => nonce + 1);
61 return;
62 }
63 onAnswer(interaction.id, "accept", coerceStructuredValues(schema.fields, values).content);
64 };
65
66 const openLink = () => {
67 if (!interaction.url) return;
68 setOpenedLink(true);
69 onOpenLink?.(interaction.url);
70 };
71
72 const validationHint = schema.unsupported
73 ? t("mcp.interaction.unsupportedSchema")
74 : showErrors && firstIssue
75 ? firstIssue.required
76 ? t("mcp.interaction.required", { label: firstIssue.label })
77 : t("mcp.interaction.invalidValue", { label: firstIssue.label })
78 : t("mcp.interaction.reviewHint");
79
80 return (
81 <PromptShelf
82 className="mcp-interaction-shelf"
83 titleId={`mcp-interaction-${interaction.id}`}
84 descriptionId={messageID}
85 title={t("mcp.interaction.title")}
86 badges={
87 <>
88 <PromptBadge>{interaction.server}</PromptBadge>
89 <PromptBadge>{mode === "url" ? t("mcp.interaction.modeUrl") : t("mcp.interaction.modeForm")}</PromptBadge>
90 </>
91 }
92 decision
93 footer={
94 mode === "url" ? (
95 <div className="prompt-shelf-bar">
96 <button
97 type="button"
98 className="btn btn--small prompt-shelf-bar__quiet"
99 onClick={() => onAnswer(interaction.id, "decline")}
100 disabled={busy}
101 >
102 {t("mcp.interaction.decline")}
103 </button>
104 <span className="prompt-shelf-bar-hint" aria-live="polite">
105 {openedLink ? t("mcp.interaction.urlOpenedHint") : t("mcp.interaction.urlHint")}
106 </span>
107 <div className="prompt-shelf-bar-actions" role="group" aria-label={t("mcp.interaction.actions")}>
108 <button type="button" className="btn btn--small" onClick={() => onAnswer(interaction.id, "cancel")} disabled={busy}>
109 {t("mcp.interaction.cancel")}
110 </button>
111 <button type="button" className="btn btn--small" onClick={openLink} disabled={busy || !interaction.url}>
112 {t("mcp.interaction.openUrl", { host: targetHost || t("mcp.interaction.link") })}
113 </button>
114 <button type="button" className="btn btn--small btn--primary" onClick={() => onAnswer(interaction.id, "accept")} disabled={busy}>
115 {t("mcp.interaction.accept")}
116 </button>
117 </div>
118 </div>
119 ) : (
120 <div className="prompt-shelf-bar">
121 <button
122 type="button"
123 className="btn btn--small prompt-shelf-bar__quiet"
124 onClick={() => onAnswer(interaction.id, "decline")}
125 disabled={busy}
126 >
127 {t("mcp.interaction.decline")}
128 </button>
129 <span className="prompt-shelf-bar-hint" aria-live="polite">{validationHint}</span>
130 <div className="prompt-shelf-bar-actions" role="group" aria-label={t("mcp.interaction.actions")}>
131 <button type="button" className="btn btn--small" onClick={() => onAnswer(interaction.id, "cancel")} disabled={busy}>
132 {t("mcp.interaction.cancel")}
133 </button>
134 <button type="button" className="btn btn--small btn--primary" onClick={submit} disabled={busy || schema.unsupported}>
135 {t("mcp.interaction.submit")}
136 </button>
137 </div>
138 </div>
139 )
140 }
141 >
142 <p id={messageID} className="mcp-interaction-message">{interaction.message}</p>
143 {mode === "url" ? (
144 <div className="mcp-interaction-url">
145 <span>{interaction.server}</span>
146 <span aria-hidden="true">→</span>
147 <strong>{targetHost || t("mcp.interaction.unknownHost")}</strong>
148 </div>
149 ) : schema.fields.length > 0 ? (
150 <>
151 {schema.unsupported ? (
152 <p className="mcp-interaction-noschema" role="alert">{t("mcp.interaction.unsupportedSchema")}</p>
153 ) : null}
154 <StructuredForm
155 fields={schema.fields}
156 values={values}
157 onChange={setValues}
158 disabled={busy}
159 showErrors={showErrors}
160 focusInvalidNonce={focusInvalidNonce}
161 />
162 <p className="mcp-interaction-privacy">{t("mcp.interaction.formPrivacyHint")}</p>
163 </>
164 ) : schema.unsupported ? (
165 <p className="mcp-interaction-noschema" role="alert">{t("mcp.interaction.unsupportedSchema")}</p>
166 ) : (
167 <p className="mcp-interaction-noschema">{t("mcp.interaction.confirmOnly")}</p>
168 )}
169 </PromptShelf>
170 );
171 }
172
172 lines Plain Text