Add analysis feature
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Loader2, Plus, Send, Trash2 } from "lucide-react";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
|
||||
import { ResourceSelectField, ToggleRow } from "@/components/assistant-editor/editor-controls";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -13,47 +12,13 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type {
|
||||
AnalysisConfig,
|
||||
AnalysisField,
|
||||
AnalysisFieldType,
|
||||
} from "@/lib/api";
|
||||
|
||||
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: "",
|
||||
};
|
||||
}
|
||||
export type { AnalysisConfig, AnalysisField, AnalysisFieldType } from "@/lib/api";
|
||||
|
||||
const FIELD_TYPE_OPTIONS: Array<{
|
||||
value: AnalysisFieldType;
|
||||
@@ -76,37 +41,6 @@ function createField(): AnalysisField {
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -186,7 +120,7 @@ export function AnalysisConfigEditor({
|
||||
还没有关键信息字段
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
点击右上角加号添加字段,例如「客户意向」「是否预约」等。
|
||||
点击右上角加号添加字段,例如 customer_intent、booked。
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -201,7 +135,7 @@ export function AnalysisConfigEditor({
|
||||
onChange={(event) =>
|
||||
updateField(field.id, { name: event.target.value })
|
||||
}
|
||||
placeholder="字段名"
|
||||
placeholder="字段名,如 customer_intent"
|
||||
aria-label={`字段 ${index + 1} 名称`}
|
||||
className="h-9 border-hairline-strong bg-background"
|
||||
/>
|
||||
@@ -276,111 +210,3 @@ export function AnalysisConfigEditor({
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,11 +3,6 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
AnalysisConfigEditor,
|
||||
WebhookConfigEditor,
|
||||
defaultAnalysisConfig,
|
||||
defaultWebhookConfig,
|
||||
type AnalysisConfig,
|
||||
type WebhookConfig,
|
||||
} from "@/components/assistant-editor/analysis-config";
|
||||
import {
|
||||
Braces,
|
||||
@@ -22,7 +17,6 @@ import {
|
||||
Save,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
Webhook,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -105,7 +99,6 @@ const promptSections = [
|
||||
{ id: "interaction", label: "交互策略" },
|
||||
{ id: "variables", label: "动态变量" },
|
||||
{ id: "analysis", label: "分析" },
|
||||
{ id: "webhook", label: "Webhook" },
|
||||
] as const;
|
||||
|
||||
type PromptSectionId = (typeof promptSections)[number]["id"];
|
||||
@@ -169,14 +162,7 @@ export function PromptEditor({
|
||||
interaction: 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 [activeSection, setActiveSection] =
|
||||
useState<PromptSectionId>("conversation");
|
||||
@@ -781,31 +767,14 @@ export function PromptEditor({
|
||||
description="通话结束后自动提取关键信息"
|
||||
>
|
||||
<AnalysisConfigEditor
|
||||
config={analysisConfig}
|
||||
onChange={setAnalysisConfig}
|
||||
config={form.analysisConfig}
|
||||
onChange={(analysisConfig) =>
|
||||
updateForm("analysisConfig", analysisConfig)
|
||||
}
|
||||
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>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
AnalysisConfig,
|
||||
DynamicVariableDefinition,
|
||||
KnowledgeRetrievalConfig,
|
||||
StartupConfig,
|
||||
@@ -12,6 +13,7 @@ export type AssistantForm = {
|
||||
greeting: string;
|
||||
prompt: string;
|
||||
dynamicVariableDefinitions: Record<string, DynamicVariableDefinition>;
|
||||
analysisConfig: AnalysisConfig;
|
||||
runtimeMode: RuntimeMode;
|
||||
realtimeModel: string;
|
||||
model: string;
|
||||
|
||||
@@ -157,6 +157,11 @@ function blankPromptForm(name: string): AssistantForm {
|
||||
greeting: "",
|
||||
prompt: "",
|
||||
dynamicVariableDefinitions: {},
|
||||
analysisConfig: {
|
||||
enabled: false,
|
||||
modelResourceId: "",
|
||||
fields: [],
|
||||
},
|
||||
runtimeMode: "pipeline",
|
||||
realtimeModel: "",
|
||||
model: "",
|
||||
@@ -406,6 +411,11 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
greeting: a.greeting,
|
||||
prompt: a.prompt,
|
||||
dynamicVariableDefinitions: a.dynamicVariableDefinitions ?? {},
|
||||
analysisConfig: a.analysisConfig ?? {
|
||||
enabled: false,
|
||||
modelResourceId: "",
|
||||
fields: [],
|
||||
},
|
||||
runtimeMode: a.runtimeMode,
|
||||
realtimeModel: a.modelResourceIds.Realtime ?? "",
|
||||
model: a.modelResourceIds.LLM ?? "",
|
||||
@@ -481,6 +491,11 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
toolIds: [],
|
||||
prompt: "",
|
||||
dynamicVariableDefinitions: {},
|
||||
analysisConfig: {
|
||||
enabled: false,
|
||||
modelResourceId: "",
|
||||
fields: [],
|
||||
},
|
||||
apiUrl: "",
|
||||
apiKey: "",
|
||||
appId: "",
|
||||
@@ -545,6 +560,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
toolIds: form.toolIds,
|
||||
prompt: form.prompt,
|
||||
dynamicVariableDefinitions: effectiveDynamicVariableDefinitions,
|
||||
analysisConfig: form.analysisConfig,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -114,6 +114,11 @@ function baseUpsert(over: Partial<AssistantUpsert>): AssistantUpsert {
|
||||
toolIds: [],
|
||||
prompt: "",
|
||||
dynamicVariableDefinitions: {},
|
||||
analysisConfig: {
|
||||
enabled: false,
|
||||
modelResourceId: "",
|
||||
fields: [],
|
||||
},
|
||||
apiUrl: "",
|
||||
apiKey: "",
|
||||
appId: "",
|
||||
|
||||
@@ -425,6 +425,35 @@ export function HistoryPage() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!dialogOpen ||
|
||||
!detail ||
|
||||
!["pending", "processing"].includes(detail.analysis.status)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let stopped = false;
|
||||
let loadingLatest = false;
|
||||
const timer = window.setInterval(() => {
|
||||
if (loadingLatest) return;
|
||||
loadingLatest = true;
|
||||
void conversationsApi
|
||||
.get(detail.id)
|
||||
.then((latest) => {
|
||||
if (!stopped) setDetail(latest);
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
loadingLatest = false;
|
||||
});
|
||||
}, 2000);
|
||||
return () => {
|
||||
stopped = true;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [detail, dialogOpen]);
|
||||
|
||||
const remove = useCallback(
|
||||
async (conversation: Conversation) => {
|
||||
const label = conversation.assistantName || "调试会话";
|
||||
@@ -729,7 +758,9 @@ export function HistoryPage() {
|
||||
{detail && detailTab === "session" && (
|
||||
<SessionInfoPanel detail={detail} />
|
||||
)}
|
||||
{detail && detailTab === "analysis" && <AnalysisPanel />}
|
||||
{detail && detailTab === "analysis" && (
|
||||
<AnalysisPanel analysis={detail.analysis} />
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -879,7 +910,79 @@ function SessionInfoPanel({ detail }: { detail: ConversationDetail }) {
|
||||
);
|
||||
}
|
||||
|
||||
function AnalysisPanel() {
|
||||
function analysisValue(value: unknown): string {
|
||||
if (value === null || value === undefined || value === "") return "未提取到";
|
||||
if (typeof value === "boolean") return value ? "是" : "否";
|
||||
if (typeof value === "object") return JSON.stringify(value);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function AnalysisPanel({
|
||||
analysis,
|
||||
}: {
|
||||
analysis: ConversationDetail["analysis"];
|
||||
}) {
|
||||
if (analysis.status === "pending" || analysis.status === "processing") {
|
||||
return (
|
||||
<div className="flex min-h-48 items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
正在分析通话内容
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (analysis.status === "failed") {
|
||||
return (
|
||||
<div className="rounded-2xl border border-destructive/25 bg-destructive/5 px-4 py-4">
|
||||
<div className="text-sm font-medium text-destructive">分析失败</div>
|
||||
<p className="mt-1.5 break-words text-xs leading-5 text-muted-foreground">
|
||||
{analysis.error || "分析服务没有返回有效结果。"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (analysis.status === "completed") {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="caption-label text-muted-soft">关键信息</div>
|
||||
{analysis.completedAt && (
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
完成于 {formatDate(analysis.completedAt)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="divide-y divide-hairline overflow-hidden rounded-2xl border border-hairline bg-background">
|
||||
{analysis.fields.map((field) => (
|
||||
<div
|
||||
key={field.name}
|
||||
className="grid grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] gap-4 px-4 py-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="break-all text-sm font-medium text-foreground">
|
||||
{field.name}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[11px] text-muted-soft">
|
||||
{field.type}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"break-words text-sm text-foreground",
|
||||
(field.value === null || field.value === undefined) &&
|
||||
"text-muted-soft",
|
||||
)}
|
||||
>
|
||||
{analysisValue(field.value)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-48 flex-col items-center justify-center rounded-2xl border border-dashed border-hairline-strong bg-canvas-soft px-5 py-10 text-center">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-surface-strong text-foreground">
|
||||
@@ -889,7 +992,7 @@ function AnalysisPanel() {
|
||||
暂无分析结果
|
||||
</div>
|
||||
<p className="mt-1.5 max-w-xs text-xs leading-5 text-muted-foreground">
|
||||
通话后分析接入后,将在这里展示从对话中提取的关键信息字段。
|
||||
该会话未开启通话后分析,或没有可分析的对话内容。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -102,6 +102,11 @@ function baseUpsertFromTemplate(
|
||||
toolIds: [],
|
||||
prompt: template.prompt,
|
||||
dynamicVariableDefinitions: {},
|
||||
analysisConfig: {
|
||||
enabled: false,
|
||||
modelResourceId: "",
|
||||
fields: [],
|
||||
},
|
||||
apiUrl: "",
|
||||
apiKey: "",
|
||||
appId: "",
|
||||
|
||||
@@ -246,6 +246,27 @@ export type SystemToolKind =
|
||||
| "skip_turn"
|
||||
| "request_human_handoff";
|
||||
|
||||
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[];
|
||||
};
|
||||
|
||||
/** 后端 AssistantOut(宽表 STI:瘦字段平铺,workflow 用 graph)。apiKey 读时打码 */
|
||||
export type Assistant = {
|
||||
id: string;
|
||||
@@ -264,6 +285,7 @@ export type Assistant = {
|
||||
toolIds: string[];
|
||||
prompt: string;
|
||||
dynamicVariableDefinitions: Record<string, DynamicVariableDefinition>;
|
||||
analysisConfig: AnalysisConfig;
|
||||
apiUrl: string;
|
||||
apiKey: string;
|
||||
appId: string;
|
||||
@@ -354,6 +376,16 @@ export type ConversationDetail = Conversation & {
|
||||
};
|
||||
workflowTrace?: Array<Record<string, unknown>>;
|
||||
};
|
||||
analysis: {
|
||||
status: "none" | "pending" | "processing" | "completed" | "failed";
|
||||
fields: Array<{
|
||||
name: string;
|
||||
type: AnalysisFieldType;
|
||||
value: unknown;
|
||||
}>;
|
||||
error: string;
|
||||
completedAt: string | null;
|
||||
};
|
||||
messages: ConversationMessage[];
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user