Add analysis feature

This commit is contained in:
Xin Wang
2026-08-07 10:59:47 +08:00
parent 485ad623d4
commit 56a32e81ca
23 changed files with 768 additions and 223 deletions

View File

@@ -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_intentbooked
</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>
);
}

View File

@@ -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>

View File

@@ -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;

View File

@@ -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,
}),
);
}

View File

@@ -114,6 +114,11 @@ function baseUpsert(over: Partial<AssistantUpsert>): AssistantUpsert {
toolIds: [],
prompt: "",
dynamicVariableDefinitions: {},
analysisConfig: {
enabled: false,
modelResourceId: "",
fields: [],
},
apiUrl: "",
apiKey: "",
appId: "",

View File

@@ -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>
);

View File

@@ -102,6 +102,11 @@ function baseUpsertFromTemplate(
toolIds: [],
prompt: template.prompt,
dynamicVariableDefinitions: {},
analysisConfig: {
enabled: false,
modelResourceId: "",
fields: [],
},
apiUrl: "",
apiKey: "",
appId: "",

View File

@@ -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[];
};