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 <cursoragent@cursor.com>
This commit is contained in:
Xin Wang
2026-08-06 16:57:31 +08:00
parent 4d00e719c4
commit b6e2ac5765
2 changed files with 444 additions and 0 deletions

View File

@@ -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<string, unknown> = {};
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<AnalysisConfig>) {
onChange({ ...config, ...partial });
}
function updateField(id: string, partial: Partial<AnalysisField>) {
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 (
<div className="space-y-3">
<ToggleRow
title="通话后分析"
hint="通话结束后,使用所选模型从对话中提取关键信息。"
checked={config.enabled}
onChange={(enabled) => patch({ enabled })}
/>
{config.enabled && (
<>
<ResourceSelectField
label="分析模型"
value={config.modelResourceId}
onChange={(modelResourceId) => patch({ modelResourceId })}
options={modelOptions}
noneLabel="请选择"
/>
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium text-foreground">
</div>
<p className="mt-0.5 text-xs leading-5 text-muted-foreground">
</p>
</div>
<Button
type="button"
variant="outline"
size="icon-sm"
className="shrink-0 border-hairline-strong text-muted-foreground hover:text-foreground"
onClick={addField}
aria-label="添加字段"
title="添加字段"
>
<Plus size={15} />
</Button>
</div>
{config.fields.length === 0 ? (
<div className="rounded-xl border border-dashed border-hairline-strong bg-canvas-soft px-4 py-5 text-center">
<div className="text-sm font-medium text-foreground">
</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
</p>
</div>
) : (
<div className="space-y-1.5">
{config.fields.map((field, index) => (
<div
key={field.id}
className="grid grid-cols-[minmax(0,1fr)_112px_minmax(0,1.2fr)_32px] items-center gap-2"
>
<Input
value={field.name}
onChange={(event) =>
updateField(field.id, { name: event.target.value })
}
placeholder="字段名"
aria-label={`字段 ${index + 1} 名称`}
className="h-9 border-hairline-strong bg-background"
/>
<Select
value={field.type}
onValueChange={(type: AnalysisFieldType) =>
updateField(field.id, {
type,
enumValues: type === "enum" ? field.enumValues : [],
})
}
>
<SelectTrigger
aria-label={`字段 ${index + 1} 类型`}
className="h-9 w-full border-hairline-strong bg-background"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{FIELD_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{field.type === "enum" ? (
<Input
value={field.enumValues.join(", ")}
onChange={(event) =>
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"
/>
) : (
<Input
value={field.description}
onChange={(event) =>
updateField(field.id, {
description: event.target.value,
})
}
placeholder="说明(可选)"
aria-label={`字段 ${index + 1} 说明`}
className="h-9 border-hairline-strong bg-background"
/>
)}
<Button
type="button"
size="icon-sm"
variant="ghost"
className="shrink-0 text-muted-soft hover:text-destructive"
onClick={() => removeField(field.id)}
aria-label={`删除字段 ${index + 1}`}
>
<Trash2 size={14} />
</Button>
</div>
))}
</div>
)}
</div>
</>
)}
</div>
);
}
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<string | null>(null);
function patch(partial: Partial<WebhookConfig>) {
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 (
<div className="space-y-3">
<label className="block">
<span className="mb-1.5 block text-sm font-medium text-foreground">
Webhook URL
</span>
<Input
value={config.url}
onChange={(event) => patch({ url: event.target.value })}
placeholder="https://example.com/webhooks/call-analysis"
className="border-hairline-strong bg-background"
/>
</label>
<label className="block">
<span className="mb-1.5 block text-sm font-medium text-foreground">
</span>
<Input
type="password"
value={config.secret}
onChange={(event) => patch({ secret: event.target.value })}
placeholder="可选,用于验证 Webhook 请求来源"
className="border-hairline-strong bg-background"
/>
</label>
<div className="flex flex-wrap items-center gap-3 border-t border-hairline pt-3">
<Button
type="button"
variant="outline"
className="gap-2 border-hairline-strong"
disabled={testStatus === "loading"}
onClick={() => void sendTestEvent()}
>
{testStatus === "loading" ? (
<Loader2 size={15} className="animate-spin" />
) : (
<Send size={15} />
)}
</Button>
{testStatus === "success" && (
<span className="text-xs text-emerald-600 dark:text-emerald-400">
</span>
)}
{testStatus === "error" && (
<span className="text-xs text-destructive"></span>
)}
</div>
{testMessage && (
<p
role="status"
className={`rounded-xl border px-3.5 py-3 text-xs leading-5 ${
testStatus === "error"
? "border-destructive/30 bg-destructive/5 text-destructive"
: "border-hairline bg-canvas-soft text-muted-foreground"
}`}
>
{testMessage}
</p>
)}
</div>
);
}

View File

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