From b6e2ac576571790dfc53cf1b4a0f27bfcc46cbe6 Mon Sep 17 00:00:00 2001 From: Xin Wang Date: Thu, 6 Aug 2026 16:57:31 +0800 Subject: [PATCH] feat(frontend): add mock post-call analysis and webhook sections to prompt editor Introduce Analysis and Webhook section cards with local mock state so operators can configure post-call extraction fields and delivery before backend wiring. Co-authored-by: Cursor --- .../assistant-editor/analysis-config.tsx | 386 ++++++++++++++++++ .../assistant-editor/prompt-editor.tsx | 58 +++ 2 files changed, 444 insertions(+) create mode 100644 frontend/src/components/assistant-editor/analysis-config.tsx diff --git a/frontend/src/components/assistant-editor/analysis-config.tsx b/frontend/src/components/assistant-editor/analysis-config.tsx new file mode 100644 index 0000000..bcde654 --- /dev/null +++ b/frontend/src/components/assistant-editor/analysis-config.tsx @@ -0,0 +1,386 @@ +"use client"; + +import { useState } from "react"; +import { Loader2, Plus, Send, Trash2 } from "lucide-react"; + +import { ResourceSelectField, ToggleRow } from "@/components/assistant-editor/editor-controls"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +export type AnalysisFieldType = + | "string" + | "boolean" + | "integer" + | "number" + | "enum"; + +export type AnalysisField = { + id: string; + name: string; + type: AnalysisFieldType; + description: string; + enumValues: string[]; +}; + +export type AnalysisConfig = { + enabled: boolean; + modelResourceId: string; + fields: AnalysisField[]; +}; + +export type WebhookConfig = { + url: string; + secret: string; +}; + +export function defaultAnalysisConfig(): AnalysisConfig { + return { + enabled: false, + modelResourceId: "", + fields: [], + }; +} + +export function defaultWebhookConfig(): WebhookConfig { + return { + url: "", + secret: "", + }; +} + +const FIELD_TYPE_OPTIONS: Array<{ + value: AnalysisFieldType; + label: string; +}> = [ + { value: "string", label: "string" }, + { value: "boolean", label: "boolean" }, + { value: "integer", label: "integer" }, + { value: "number", label: "number" }, + { value: "enum", label: "enum" }, +]; + +function createField(): AnalysisField { + return { + id: crypto.randomUUID(), + name: "", + type: "string", + description: "", + enumValues: [], + }; +} + +function buildMockPayload(fields: AnalysisField[]) { + const extracted: Record = {}; + for (const field of fields) { + if (!field.name.trim()) continue; + switch (field.type) { + case "boolean": + extracted[field.name] = true; + break; + case "integer": + extracted[field.name] = 1; + break; + case "number": + extracted[field.name] = 1.5; + break; + case "enum": + extracted[field.name] = field.enumValues[0] ?? "option_a"; + break; + default: + extracted[field.name] = "示例值"; + } + } + + return { + event: "call.analysis.completed", + conversation_id: "mock-conv-001", + assistant_id: "mock-assistant-001", + timestamp: new Date().toISOString(), + analysis: extracted, + }; +} + +type AnalysisConfigEditorProps = { + config: AnalysisConfig; + onChange: (config: AnalysisConfig) => void; + modelOptions: Array<{ value: string; label: string }>; +}; + +export function AnalysisConfigEditor({ + config, + onChange, + modelOptions, +}: AnalysisConfigEditorProps) { + function patch(partial: Partial) { + onChange({ ...config, ...partial }); + } + + function updateField(id: string, partial: Partial) { + patch({ + fields: config.fields.map((field) => + field.id === id ? { ...field, ...partial } : field, + ), + }); + } + + function removeField(id: string) { + patch({ fields: config.fields.filter((field) => field.id !== id) }); + } + + function addField() { + patch({ fields: [...config.fields, createField()] }); + } + + return ( +
+ patch({ enabled })} + /> + + {config.enabled && ( + <> + patch({ modelResourceId })} + options={modelOptions} + noneLabel="请选择" + /> + +
+
+
+
+ 关键信息字段 +
+

+ 定义通话结束后需要从对话中提取的结构化字段。 +

+
+ +
+ + {config.fields.length === 0 ? ( +
+
+ 还没有关键信息字段 +
+

+ 点击右上角加号添加字段,例如「客户意向」「是否预约」等。 +

+
+ ) : ( +
+ {config.fields.map((field, index) => ( +
+ + updateField(field.id, { name: event.target.value }) + } + placeholder="字段名" + aria-label={`字段 ${index + 1} 名称`} + className="h-9 border-hairline-strong bg-background" + /> + + {field.type === "enum" ? ( + + updateField(field.id, { + enumValues: event.target.value + .split(",") + .map((value) => value.trim()) + .filter(Boolean), + }) + } + placeholder="枚举值,逗号分隔" + aria-label={`字段 ${index + 1} 枚举值`} + className="h-9 border-hairline-strong bg-background" + /> + ) : ( + + updateField(field.id, { + description: event.target.value, + }) + } + placeholder="说明(可选)" + aria-label={`字段 ${index + 1} 说明`} + className="h-9 border-hairline-strong bg-background" + /> + )} + +
+ ))} +
+ )} +
+ + )} +
+ ); +} + +type WebhookConfigEditorProps = { + config: WebhookConfig; + onChange: (config: WebhookConfig) => void; + analysisFields: AnalysisField[]; +}; + +export function WebhookConfigEditor({ + config, + onChange, + analysisFields, +}: WebhookConfigEditorProps) { + const [testStatus, setTestStatus] = useState< + "idle" | "loading" | "success" | "error" + >("idle"); + const [testMessage, setTestMessage] = useState(null); + + function patch(partial: Partial) { + onChange({ ...config, ...partial }); + } + + async function sendTestEvent() { + if (!config.url.trim()) { + setTestStatus("error"); + setTestMessage("请先填写 Webhook URL。"); + return; + } + + setTestStatus("loading"); + setTestMessage(null); + + const payload = buildMockPayload(analysisFields); + + await new Promise((resolve) => window.setTimeout(resolve, 900)); + + setTestStatus("success"); + setTestMessage( + `测试事件已模拟发送至 ${config.url.trim()}(Mock,未实际请求网络)。示例 payload:${JSON.stringify(payload)}`, + ); + } + + return ( +
+ + + + +
+ + {testStatus === "success" && ( + + 模拟发送成功 + + )} + {testStatus === "error" && ( + 发送失败 + )} +
+ + {testMessage && ( +

+ {testMessage} +

+ )} +
+ ); +} diff --git a/frontend/src/components/assistant-editor/prompt-editor.tsx b/frontend/src/components/assistant-editor/prompt-editor.tsx index 08a5474..214580e 100644 --- a/frontend/src/components/assistant-editor/prompt-editor.tsx +++ b/frontend/src/components/assistant-editor/prompt-editor.tsx @@ -1,16 +1,26 @@ "use client"; import { useEffect, useRef, useState } from "react"; +import { + AnalysisConfigEditor, + WebhookConfigEditor, + defaultAnalysisConfig, + defaultWebhookConfig, + type AnalysisConfig, + type WebhookConfig, +} from "@/components/assistant-editor/analysis-config"; import { Braces, Bot, Brain, Bug, + ChartLine, Database, Loader2, MessageSquareText, Save, Sparkles, + Webhook, Wrench, } from "lucide-react"; @@ -86,6 +96,8 @@ const promptSections = [ { id: "capabilities", label: "知识与工具" }, { id: "interaction", label: "交互策略" }, { id: "variables", label: "动态变量" }, + { id: "analysis", label: "分析" }, + { id: "webhook", label: "Webhook" }, ] as const; type PromptSectionId = (typeof promptSections)[number]["id"]; @@ -146,7 +158,15 @@ export function PromptEditor({ capabilities: null, interaction: null, variables: null, + analysis: null, + webhook: null, }); + const [analysisConfig, setAnalysisConfig] = useState( + defaultAnalysisConfig, + ); + const [webhookConfig, setWebhookConfig] = useState( + defaultWebhookConfig, + ); const selectedAnchorRef = useRef(null); const [activeSection, setActiveSection] = useState("conversation"); @@ -691,6 +711,44 @@ export function PromptEditor({ /> + +
{ + sectionRefs.current.analysis = element; + }} + className="scroll-mt-3 space-y-3" + > + } + title="分析" + description="通话结束后自动提取关键信息" + > + + +
+ +
{ + sectionRefs.current.webhook = element; + }} + className="scroll-mt-3 space-y-3" + > + } + title="Webhook" + description="通话分析完成后,将结果 POST 到指定地址" + > + + +