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:
386
frontend/src/components/assistant-editor/analysis-config.tsx
Normal file
386
frontend/src/components/assistant-editor/analysis-config.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user