Compare commits
5 Commits
4d00e719c4
...
485ad623d4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
485ad623d4 | ||
|
|
87b6392eea | ||
|
|
b51b768c29 | ||
|
|
723ee84925 | ||
|
|
b6e2ac5765 |
@@ -1,5 +1,5 @@
|
||||
import { AssistantPage } from "@/components/pages/AssistantPage";
|
||||
import { CreateAssistantPage } from "@/components/pages/CreateAssistantPage";
|
||||
|
||||
export default function Page() {
|
||||
return <AssistantPage mode="choose" />;
|
||||
return <CreateAssistantPage />;
|
||||
}
|
||||
|
||||
10
frontend/src/app/assistants/templates/[id]/page.tsx
Normal file
10
frontend/src/app/assistants/templates/[id]/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
export default async function Page({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
redirect(`/assistants/templates?template=${encodeURIComponent(id)}`);
|
||||
}
|
||||
10
frontend/src/app/assistants/templates/page.tsx
Normal file
10
frontend/src/app/assistants/templates/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { TemplateLibraryPage } from "@/components/pages/TemplateLibraryPage";
|
||||
|
||||
type PageProps = {
|
||||
searchParams: Promise<{ template?: string }>;
|
||||
};
|
||||
|
||||
export default async function Page({ searchParams }: PageProps) {
|
||||
const { template } = await searchParams;
|
||||
return <TemplateLibraryPage initialTemplateId={template} />;
|
||||
}
|
||||
@@ -180,7 +180,44 @@
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
/* Theme-aware scrollbars (light/dark navy tokens) */
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: color-mix(
|
||||
in srgb,
|
||||
var(--muted-soft) 48%,
|
||||
transparent
|
||||
)
|
||||
transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--muted-soft) 42%,
|
||||
transparent
|
||||
);
|
||||
border: 3px solid transparent;
|
||||
border-radius: 999px;
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--muted-soft) 64%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
letter-spacing: 0.01em;
|
||||
@@ -261,42 +298,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Explicit opt-in alias; base layer already styles all scrollbars. */
|
||||
.scrollbar-subtle {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: color-mix(
|
||||
in srgb,
|
||||
var(--muted-soft) 48%,
|
||||
transparent
|
||||
)
|
||||
transparent;
|
||||
}
|
||||
|
||||
.scrollbar-subtle::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.scrollbar-subtle::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.scrollbar-subtle::-webkit-scrollbar-thumb {
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--muted-soft) 42%,
|
||||
transparent
|
||||
);
|
||||
border: 3px solid transparent;
|
||||
border-radius: 999px;
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
.scrollbar-subtle::-webkit-scrollbar-thumb:hover {
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--muted-soft) 64%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
/* 手机通话页「开始通话」引导光晕:柔和向外扩散的涟漪环 */
|
||||
|
||||
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,28 @@
|
||||
"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,
|
||||
MoreHorizontal,
|
||||
Save,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
Webhook,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -35,6 +47,12 @@ import { SectionCard } from "@/components/editor/section-card";
|
||||
import { VisionConfigSection } from "@/components/editor/vision-config-section";
|
||||
import { TurnConfigEditor } from "@/components/turn-config-editor";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { TopbarPortal } from "@/components/layout/topbar-portal";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
@@ -86,6 +104,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"];
|
||||
@@ -106,6 +126,7 @@ type PromptEditorProps = {
|
||||
tools: Tool[];
|
||||
onBack: () => void;
|
||||
onSave: () => void;
|
||||
onDelete?: () => void | Promise<void>;
|
||||
updateForm: <K extends keyof AssistantForm>(
|
||||
key: K,
|
||||
value: AssistantForm[K],
|
||||
@@ -130,6 +151,7 @@ export function PromptEditor({
|
||||
tools,
|
||||
onBack,
|
||||
onSave,
|
||||
onDelete,
|
||||
updateForm,
|
||||
handlePromptVisionEnabledChange,
|
||||
handlePromptModelChange,
|
||||
@@ -146,11 +168,20 @@ export function PromptEditor({
|
||||
capabilities: null,
|
||||
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");
|
||||
const [debugOpen, setDebugOpen] = useState(true);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
@@ -263,6 +294,16 @@ export function PromptEditor({
|
||||
});
|
||||
}
|
||||
|
||||
async function handleDeleteSelect() {
|
||||
if (!onDelete || deleting) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await onDelete();
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<TopbarPortal>
|
||||
@@ -274,6 +315,42 @@ export function PromptEditor({
|
||||
onChange={(value) => updateForm("name", value)}
|
||||
/>
|
||||
<AssistantIdentity assistantId={assistantId} />
|
||||
{onDelete && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
className="border-hairline-strong text-muted-foreground hover:text-foreground"
|
||||
disabled={!assistantId || deleting}
|
||||
aria-label="更多操作"
|
||||
>
|
||||
{deleting ? (
|
||||
<Loader2 size={15} className="animate-spin" />
|
||||
) : (
|
||||
<MoreHorizontal size={15} />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="w-32 min-w-32 rounded-xl border border-hairline bg-popover p-1"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
className="rounded-lg"
|
||||
disabled={deleting}
|
||||
onSelect={(event) => {
|
||||
event.preventDefault();
|
||||
window.setTimeout(() => void handleDeleteSelect(), 0);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
删除
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
@@ -691,6 +768,44 @@ export function PromptEditor({
|
||||
/>
|
||||
</SectionCard>
|
||||
</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>
|
||||
|
||||
@@ -98,7 +98,7 @@ export function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-3 py-5 pr-2 [scrollbar-width:thin] [scrollbar-color:var(--hairline-strong)_transparent]">
|
||||
<nav className="scrollbar-subtle min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-3 py-5 pr-2">
|
||||
<div className="space-y-1">
|
||||
<NavButton
|
||||
active={pathname === "/"}
|
||||
|
||||
@@ -3,19 +3,16 @@
|
||||
import {
|
||||
Boxes,
|
||||
Brain,
|
||||
Check,
|
||||
Copy,
|
||||
MessageSquareText,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Plus,
|
||||
Rocket,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
Workflow,
|
||||
ChevronLeft,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
LayoutTemplate,
|
||||
Save,
|
||||
Terminal,
|
||||
Loader2,
|
||||
@@ -24,7 +21,6 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { TurnConfigEditor } from "@/components/turn-config-editor";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -111,7 +107,6 @@ type OpenCodeForm = {
|
||||
};
|
||||
|
||||
type AssistantType = "提示词" | "工作流" | "Dify" | "FastGPT" | "OpenCode";
|
||||
type BuildMethod = "提示词" | "工作流" | "智能体平台" | "OpenCode";
|
||||
|
||||
const assistantTypes: AssistantType[] = [
|
||||
"提示词",
|
||||
@@ -137,14 +132,6 @@ const typeToLabel: Record<ApiAssistantType, AssistantType> = {
|
||||
fastgpt: "FastGPT",
|
||||
opencode: "OpenCode",
|
||||
};
|
||||
const typeFromBuildMethod: Record<
|
||||
Exclude<BuildMethod, "智能体平台">,
|
||||
ApiAssistantType
|
||||
> = {
|
||||
提示词: "prompt",
|
||||
工作流: "workflow",
|
||||
OpenCode: "opencode",
|
||||
};
|
||||
|
||||
// 后端 type → 编辑器视图(工作流暂为占位页)
|
||||
const typeToView = {
|
||||
@@ -155,13 +142,12 @@ const typeToView = {
|
||||
workflow: "workflow",
|
||||
} as const;
|
||||
|
||||
type View = "list" | "choose" | "loading" | (typeof typeToView)[ApiAssistantType];
|
||||
type View = "list" | "loading" | (typeof typeToView)[ApiAssistantType];
|
||||
|
||||
// 路由驱动的页面模式:
|
||||
// /assistants → list | /assistants/new → choose(引导,确认即建库) | /assistants/[id] → edit
|
||||
// /assistants → list | /assistants/[id] → edit
|
||||
export type AssistantPageProps =
|
||||
| { mode: "list" }
|
||||
| { mode: "choose" }
|
||||
| { mode: "edit"; assistantId: string };
|
||||
|
||||
// 各类型的空白表单模板(新建用)
|
||||
@@ -229,47 +215,6 @@ function formatTimestamp(iso?: string | null): string {
|
||||
)}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
type AssistantTypeOption = {
|
||||
type: BuildMethod;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
/** 提示词、工作流、智能体平台已落地;OpenCode 暂时显示即将上线 */
|
||||
available: boolean;
|
||||
};
|
||||
|
||||
const assistantTypeOptions: AssistantTypeOption[] = [
|
||||
{
|
||||
type: "提示词",
|
||||
label: "使用提示词构建",
|
||||
description: "通过提示词、模型与语音快速搭建对话助手,适合大多数场景。",
|
||||
icon: <MessageSquareText size={20} />,
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
type: "工作流",
|
||||
label: "使用工作流构建",
|
||||
description: "用可视化编排串联多个节点,适合多步骤、带分支的复杂流程。",
|
||||
icon: <Workflow size={20} />,
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
type: "智能体平台",
|
||||
label: "从智能体平台创建",
|
||||
description:
|
||||
"接入 Dify、FastGPT 等智能体平台,复用平台中的提示词、知识库和工作流配置。",
|
||||
icon: <Boxes size={20} />,
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
type: "OpenCode",
|
||||
label: "使用 OpenCode 构建",
|
||||
description: "对接 OpenCode 服务,通过提示词驱动代码助手并支持实时语音对话。",
|
||||
icon: <Terminal size={20} />,
|
||||
available: false,
|
||||
},
|
||||
];
|
||||
|
||||
type AssistantListItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -313,7 +258,6 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
const [tools, setTools] = useState<Tool[]>([]);
|
||||
// 视图由路由模式决定;仅编辑模式需要先 loading,等拿到助手类型后切换
|
||||
const [view, setView] = useState<View>(() => {
|
||||
if (props.mode === "choose") return "choose";
|
||||
if (props.mode === "edit") return "loading";
|
||||
return "list";
|
||||
});
|
||||
@@ -321,13 +265,6 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
const [typeFilter, setTypeFilter] = useState<TypeFilter>("全部");
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>("newest");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
// choose 步骤的草稿:名称与已选类型,确认后直接建库并进入编辑页
|
||||
// (工作流占位页也用它展示名称与类型)
|
||||
const [draftName, setDraftName] = useState("");
|
||||
const [draftType, setDraftType] = useState<BuildMethod | null>(null);
|
||||
// 引导页:创建请求进行中 / 创建失败
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
// 已保存基线(当前类型表单的 JSON);与表单不一致时保存按钮才可点击
|
||||
const [savedSnapshot, setSavedSnapshot] = useState<string | null>(null);
|
||||
|
||||
@@ -499,33 +436,6 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
router.push(`/assistants/${assistant.id}`);
|
||||
}
|
||||
|
||||
// 引导页确认:直接创建到数据库拿到 id,再进入该助手的编辑页
|
||||
// 智能体平台默认先建 dify,具体平台在编辑页再选
|
||||
async function confirmType() {
|
||||
if (!draftName.trim() || !draftType || creating) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCreating(true);
|
||||
setCreateError(null);
|
||||
try {
|
||||
const assistantType: ApiAssistantType =
|
||||
draftType === "智能体平台"
|
||||
? "dify"
|
||||
: typeFromBuildMethod[draftType];
|
||||
const created = await assistantsApi.create(
|
||||
baseUpsert({
|
||||
name: draftName.trim(),
|
||||
type: assistantType,
|
||||
}),
|
||||
);
|
||||
router.push(`/assistants/${created.id}`);
|
||||
} catch (error) {
|
||||
setCreateError(error instanceof Error ? error.message : "创建失败");
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
// 复制助手:服务端整行复制(含真 key,密钥不经浏览器)
|
||||
async function handleDuplicate(assistant: AssistantListItem) {
|
||||
try {
|
||||
@@ -639,6 +549,19 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
);
|
||||
}
|
||||
|
||||
async function handleDeleteCurrentAssistant() {
|
||||
if (!editingId) return;
|
||||
const assistantName = form.name.trim() || "未命名助手";
|
||||
if (!window.confirm(`确认删除助手“${assistantName}”?`)) return;
|
||||
setSaveError(null);
|
||||
try {
|
||||
await assistantsApi.remove(editingId);
|
||||
router.push("/assistants");
|
||||
} catch (error) {
|
||||
setSaveError(error instanceof Error ? error.message : "删除失败");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 外部智能体平台(Dify / FastGPT)----
|
||||
function fillAgentPlatformForm(a: Assistant): AgentPlatformForm {
|
||||
const next: AgentPlatformForm = {
|
||||
@@ -1001,10 +924,23 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
title="助手列表"
|
||||
description="管理已有的视频助手,支持提示词、工作流、Dify 和 FastGPT 类型。"
|
||||
action={
|
||||
<Button className="w-full shrink-0 gap-2 sm:w-auto" onClick={startCreate}>
|
||||
<Plus size={16} />
|
||||
创建助手
|
||||
</Button>
|
||||
<div className="flex w-full shrink-0 flex-col gap-2 sm:w-auto sm:flex-row">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full min-w-[7.5rem] gap-2 border-hairline-strong px-4 text-foreground hover:bg-surface-strong sm:w-auto"
|
||||
onClick={() => router.push("/assistants/templates")}
|
||||
>
|
||||
<LayoutTemplate size={16} />
|
||||
模版库
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full min-w-[7.5rem] shrink-0 gap-2 px-4 sm:w-auto"
|
||||
onClick={startCreate}
|
||||
>
|
||||
<Plus size={16} />
|
||||
创建助手
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ListPageSection>
|
||||
@@ -1178,120 +1114,6 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (view === "choose") {
|
||||
return (
|
||||
<ListPageLayout
|
||||
title="创建助手"
|
||||
className="max-w-[1180px]"
|
||||
description="先为助手取个名字,再选择构建方式。确认后将立即创建助手并进入编辑页。"
|
||||
action={
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full shrink-0 gap-2 border-hairline-strong text-muted-foreground hover:text-foreground sm:w-auto"
|
||||
onClick={() => router.push("/assistants")}
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
返回列表
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<ListPageSection>
|
||||
<label className="block">
|
||||
<div className="mb-2 text-sm font-medium text-foreground">
|
||||
助手名称
|
||||
</div>
|
||||
<Input
|
||||
value={draftName}
|
||||
autoFocus
|
||||
onChange={(event) => setDraftName(event.target.value)}
|
||||
placeholder="请输入助手名称"
|
||||
className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
|
||||
/>
|
||||
</label>
|
||||
</ListPageSection>
|
||||
|
||||
<section className="flex flex-col gap-3">
|
||||
<div className="text-sm font-medium text-foreground">构建方式</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{assistantTypeOptions.map((option) => {
|
||||
const selected = draftType === option.type;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.type}
|
||||
type="button"
|
||||
onClick={() => setDraftType(option.type)}
|
||||
className={`group relative flex flex-col gap-4 rounded-2xl border bg-card p-5 text-left transition-colors ${
|
||||
selected
|
||||
? "border-primary ring-1 ring-primary"
|
||||
: "border-hairline hover:border-hairline-strong"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-surface-strong text-foreground">
|
||||
{option.icon}
|
||||
</div>
|
||||
|
||||
{selected ? (
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-primary text-primary-foreground">
|
||||
<Check size={14} />
|
||||
</span>
|
||||
) : (
|
||||
!option.available && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-6 bg-surface-strong px-3 text-xs text-muted-foreground"
|
||||
>
|
||||
即将上线
|
||||
</Badge>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-base font-medium text-foreground">
|
||||
{option.label}
|
||||
</div>
|
||||
<p className="mt-1.5 text-sm leading-6 text-muted-foreground">
|
||||
{option.description}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
{createError && (
|
||||
<span className="text-xs text-destructive">{createError}</span>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-hairline-strong text-muted-foreground hover:text-foreground"
|
||||
disabled={creating}
|
||||
onClick={() => router.push("/assistants")}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
className="gap-2"
|
||||
disabled={!draftName.trim() || !draftType || creating}
|
||||
onClick={() => void confirmType()}
|
||||
>
|
||||
{creating ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<Rocket size={16} />
|
||||
)}
|
||||
创建助手
|
||||
</Button>
|
||||
</div>
|
||||
</ListPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (view === "workflow") {
|
||||
return (
|
||||
<WorkflowPage
|
||||
@@ -1611,6 +1433,7 @@ export function AssistantPage(props: AssistantPageProps) {
|
||||
tools={tools}
|
||||
onBack={() => router.push("/assistants")}
|
||||
onSave={() => void handleSavePrompt()}
|
||||
onDelete={() => void handleDeleteCurrentAssistant()}
|
||||
updateForm={updateForm}
|
||||
handlePromptVisionEnabledChange={handlePromptVisionEnabledChange}
|
||||
handlePromptModelChange={handlePromptModelChange}
|
||||
|
||||
@@ -534,6 +534,7 @@ export function ComponentsModelsPage() {
|
||||
loading={loading}
|
||||
error={loadError || null}
|
||||
onRetry={() => void load()}
|
||||
onRowClick={openEdit}
|
||||
empty={{
|
||||
title:
|
||||
resources.length === 0
|
||||
@@ -635,14 +636,20 @@ export function ComponentsModelsPage() {
|
||||
key: "actions",
|
||||
header: "操作",
|
||||
align: "right",
|
||||
cellClassName: "flex justify-end gap-2",
|
||||
cell: (resource) => (
|
||||
<>
|
||||
<div
|
||||
className="flex justify-end gap-2"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5 border-hairline-strong text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={() => openEdit(resource)}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
openEdit(resource);
|
||||
}}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
编辑
|
||||
@@ -654,6 +661,7 @@ export function ComponentsModelsPage() {
|
||||
size="icon-sm"
|
||||
className="border-hairline-strong text-muted-foreground hover:text-foreground"
|
||||
aria-label={`${resource.name} 更多操作`}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<MoreHorizontal size={15} />
|
||||
</Button>
|
||||
@@ -682,8 +690,9 @@ export function ComponentsModelsPage() {
|
||||
className="rounded-lg"
|
||||
disabled={deletingId === resource.id}
|
||||
onSelect={(event) => {
|
||||
// Defer confirm so row onRowClick does not fire afterward.
|
||||
event.preventDefault();
|
||||
void remove(resource);
|
||||
window.setTimeout(() => void remove(resource), 0);
|
||||
}}
|
||||
>
|
||||
{deletingId === resource.id ? (
|
||||
@@ -695,7 +704,7 @@ export function ComponentsModelsPage() {
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
@@ -704,7 +713,7 @@ export function ComponentsModelsPage() {
|
||||
</ListPageLayout>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-h-[calc(100vh-3rem)] overflow-y-auto sm:max-w-6xl lg:max-w-[88rem] lg:overflow-hidden">
|
||||
<DialogContent className="scrollbar-subtle max-h-[calc(100vh-3rem)] overflow-y-auto sm:max-w-6xl lg:max-w-[88rem] lg:overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingId ? "编辑模型资源" : "添加模型资源"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -945,7 +954,7 @@ function FieldSection({
|
||||
<div
|
||||
className={[
|
||||
"space-y-4 p-4",
|
||||
scrollable ? "max-h-72 overflow-y-auto" : "",
|
||||
scrollable ? "scrollbar-subtle max-h-72 overflow-y-auto" : "",
|
||||
tall ? "lg:max-h-none lg:flex-1" : "",
|
||||
fill ? "lg:min-h-0 lg:max-h-none lg:flex-1" : "",
|
||||
].join(" ")}
|
||||
|
||||
240
frontend/src/components/pages/CreateAssistantPage.tsx
Normal file
240
frontend/src/components/pages/CreateAssistantPage.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Boxes,
|
||||
ChevronLeft,
|
||||
Loader2,
|
||||
MessageSquareText,
|
||||
Rocket,
|
||||
Terminal,
|
||||
Workflow,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
ListPageLayout,
|
||||
ListPageSection,
|
||||
} from "@/components/layout/list-page-layout";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
SelectionOptionCard,
|
||||
selectionOptionGridClassName,
|
||||
} from "@/components/ui/selection-option-card";
|
||||
import {
|
||||
assistantsApi,
|
||||
type AssistantType as ApiAssistantType,
|
||||
type AssistantUpsert,
|
||||
type KnowledgeRetrievalConfig,
|
||||
} from "@/lib/api";
|
||||
import { defaultTurnConfig } from "@/lib/turn-config";
|
||||
|
||||
type BuildMethod = "提示词" | "工作流" | "智能体平台" | "OpenCode";
|
||||
|
||||
const typeFromBuildMethod: Record<
|
||||
Exclude<BuildMethod, "智能体平台">,
|
||||
ApiAssistantType
|
||||
> = {
|
||||
提示词: "prompt",
|
||||
工作流: "workflow",
|
||||
OpenCode: "opencode",
|
||||
};
|
||||
|
||||
type AssistantTypeOption = {
|
||||
type: BuildMethod;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
/** 提示词、工作流、智能体平台已落地;OpenCode 暂时显示即将上线 */
|
||||
available: boolean;
|
||||
};
|
||||
|
||||
const assistantTypeOptions: AssistantTypeOption[] = [
|
||||
{
|
||||
type: "提示词",
|
||||
label: "使用提示词构建",
|
||||
description: "通过提示词、模型与语音快速搭建对话助手,适合大多数场景。",
|
||||
icon: <MessageSquareText size={20} />,
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
type: "工作流",
|
||||
label: "使用工作流构建",
|
||||
description: "用可视化编排串联多个节点,适合多步骤、带分支的复杂流程。",
|
||||
icon: <Workflow size={20} />,
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
type: "智能体平台",
|
||||
label: "从智能体平台创建",
|
||||
description:
|
||||
"接入 Dify、FastGPT 等智能体平台,复用平台中的提示词、知识库和工作流配置。",
|
||||
icon: <Boxes size={20} />,
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
type: "OpenCode",
|
||||
label: "使用 OpenCode 构建",
|
||||
description: "对接 OpenCode 服务,通过提示词驱动代码助手并支持实时语音对话。",
|
||||
icon: <Terminal size={20} />,
|
||||
available: false,
|
||||
},
|
||||
];
|
||||
|
||||
function defaultKnowledgeRetrievalConfig(): KnowledgeRetrievalConfig {
|
||||
return {
|
||||
mode: "automatic",
|
||||
topN: 5,
|
||||
scoreThreshold: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function baseUpsert(over: Partial<AssistantUpsert>): AssistantUpsert {
|
||||
return {
|
||||
name: "",
|
||||
type: "prompt",
|
||||
runtimeMode: "pipeline",
|
||||
greeting: "",
|
||||
enableInterrupt: true,
|
||||
turnConfig: defaultTurnConfig(),
|
||||
startup: {
|
||||
executionMode: "sequential",
|
||||
openingMode: "interruptible",
|
||||
entryMode: "wait_user",
|
||||
actions: [],
|
||||
openingMessage: null,
|
||||
},
|
||||
visionEnabled: false,
|
||||
visionModelResourceId: null,
|
||||
modelResourceIds: {},
|
||||
knowledgeBaseId: null,
|
||||
knowledgeRetrievalConfig: defaultKnowledgeRetrievalConfig(),
|
||||
toolIds: [],
|
||||
prompt: "",
|
||||
dynamicVariableDefinitions: {},
|
||||
apiUrl: "",
|
||||
apiKey: "",
|
||||
appId: "",
|
||||
graph: {},
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
export function CreateAssistantPage() {
|
||||
const router = useRouter();
|
||||
const [draftName, setDraftName] = useState("");
|
||||
const [draftType, setDraftType] = useState<BuildMethod | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
|
||||
async function handleCreate() {
|
||||
if (!draftName.trim() || !draftType || creating) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCreating(true);
|
||||
setCreateError(null);
|
||||
try {
|
||||
const assistantType: ApiAssistantType =
|
||||
draftType === "智能体平台"
|
||||
? "dify"
|
||||
: typeFromBuildMethod[draftType];
|
||||
const created = await assistantsApi.create(
|
||||
baseUpsert({
|
||||
name: draftName.trim(),
|
||||
type: assistantType,
|
||||
}),
|
||||
);
|
||||
router.push(`/assistants/${created.id}`);
|
||||
} catch (error) {
|
||||
setCreateError(error instanceof Error ? error.message : "创建失败");
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ListPageLayout
|
||||
title="创建助手"
|
||||
className="max-w-[1180px]"
|
||||
description="先为助手取个名字,再选择构建方式。确认后将立即创建助手并进入编辑页。"
|
||||
action={
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full shrink-0 gap-2 border-hairline-strong text-muted-foreground hover:text-foreground sm:w-auto"
|
||||
onClick={() => router.push("/assistants")}
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
返回列表
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<ListPageSection>
|
||||
<label className="block">
|
||||
<div className="mb-2 text-sm font-medium text-foreground">助手名称</div>
|
||||
<Input
|
||||
value={draftName}
|
||||
autoFocus
|
||||
onChange={(event) => setDraftName(event.target.value)}
|
||||
placeholder="请输入助手名称"
|
||||
className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
|
||||
/>
|
||||
</label>
|
||||
</ListPageSection>
|
||||
|
||||
<section className="flex flex-col gap-3">
|
||||
<div className="text-sm font-medium text-foreground">构建方式</div>
|
||||
|
||||
<div className={selectionOptionGridClassName}>
|
||||
{assistantTypeOptions.map((option) => (
|
||||
<SelectionOptionCard
|
||||
key={option.type}
|
||||
selected={draftType === option.type}
|
||||
icon={option.icon}
|
||||
title={option.label}
|
||||
description={option.description}
|
||||
onClick={() => setDraftType(option.type)}
|
||||
unselectedTrailing={
|
||||
!option.available ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-6 bg-surface-strong px-3 text-xs text-muted-foreground"
|
||||
>
|
||||
即将上线
|
||||
</Badge>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
{createError && (
|
||||
<span className="text-xs text-destructive">{createError}</span>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-hairline-strong text-muted-foreground hover:text-foreground"
|
||||
disabled={creating}
|
||||
onClick={() => router.push("/assistants")}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
className="gap-2"
|
||||
disabled={!draftName.trim() || !draftType || creating}
|
||||
onClick={() => void handleCreate()}
|
||||
>
|
||||
{creating ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<Rocket size={16} />
|
||||
)}
|
||||
创建助手
|
||||
</Button>
|
||||
</div>
|
||||
</ListPageLayout>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Activity,
|
||||
ArrowRight,
|
||||
Camera,
|
||||
ChartLine,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
@@ -15,6 +16,8 @@ import {
|
||||
MessageSquareText,
|
||||
MonitorSmartphone,
|
||||
MoreHorizontal,
|
||||
Pause,
|
||||
Play,
|
||||
Server,
|
||||
Trash2,
|
||||
Wrench,
|
||||
@@ -23,6 +26,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { SectionAnchorTabs } from "@/components/editor/section-anchor-tabs";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DataList, type DataListColumn } from "@/components/ui/data-list";
|
||||
@@ -34,10 +38,8 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
@@ -61,6 +63,12 @@ const PAGE_SIZE = 5;
|
||||
const channelFilters = ["全部", "WebRTC", "WebSocket"] as const;
|
||||
type ChannelFilter = (typeof channelFilters)[number];
|
||||
type SortOrder = "newest" | "oldest";
|
||||
type DetailTabId = "session" | "analysis";
|
||||
|
||||
const detailTabs = [
|
||||
{ id: "session", label: "会话信息" },
|
||||
{ id: "analysis", label: "分析" },
|
||||
] as const;
|
||||
|
||||
function formatDate(value?: string | null): string {
|
||||
if (!value) return "—";
|
||||
@@ -364,6 +372,7 @@ export function HistoryPage() {
|
||||
const [detail, setDetail] = useState<ConversationDetail | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [detailError, setDetailError] = useState("");
|
||||
const [detailTab, setDetailTab] = useState<DetailTabId>("session");
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const skipRowClickRef = useRef(false);
|
||||
|
||||
@@ -401,6 +410,7 @@ export function HistoryPage() {
|
||||
|
||||
const openDetail = useCallback(async (conversation: Conversation) => {
|
||||
setDialogOpen(true);
|
||||
setDetailTab("session");
|
||||
setDetail(null);
|
||||
setDetailError("");
|
||||
setDetailLoading(true);
|
||||
@@ -680,8 +690,8 @@ export function HistoryPage() {
|
||||
</ListPageLayout>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-h-[calc(100vh-3rem)] grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden sm:max-w-5xl">
|
||||
<DialogHeader>
|
||||
<DialogContent className="flex h-[min(94vh,980px)] w-full max-w-[calc(100%-1.5rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-7xl">
|
||||
<DialogHeader className="shrink-0 gap-1.5 px-5 py-4 sm:px-6">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<MessageSquareText size={18} />
|
||||
{detail?.assistantName || "对话详情"}
|
||||
@@ -693,44 +703,84 @@ export function HistoryPage() {
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-h-0 overflow-y-auto">
|
||||
{detailLoading && (
|
||||
<div className="flex min-h-80 items-center justify-center gap-2 text-muted-foreground">
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
正在加载对话内容
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!detailLoading && detailError && (
|
||||
<div className="flex min-h-80 items-center justify-center text-destructive">
|
||||
{detailError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail && (
|
||||
<div className="space-y-5">
|
||||
<section className="rounded-xl border border-hairline bg-surface-strong/20">
|
||||
<div className="border-b border-hairline px-4 py-3 text-sm font-medium">
|
||||
会话信息
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden border-t border-hairline sm:flex-row">
|
||||
<aside className="flex min-h-0 w-full shrink-0 flex-col border-b border-hairline sm:w-1/2 sm:border-b-0 sm:border-r">
|
||||
<SectionAnchorTabs
|
||||
ariaLabel="会话详情分区"
|
||||
sections={detailTabs}
|
||||
activeSectionId={detailTab}
|
||||
onSelect={(sectionId) =>
|
||||
setDetailTab(sectionId as DetailTabId)
|
||||
}
|
||||
className="bg-popover px-3 pt-2 sm:px-4"
|
||||
/>
|
||||
<div className="scrollbar-subtle min-h-0 flex-1 overflow-y-auto p-4 sm:p-5">
|
||||
{detailLoading && (
|
||||
<div className="flex min-h-48 items-center justify-center gap-2 text-muted-foreground">
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
正在加载
|
||||
</div>
|
||||
<div className="grid gap-4 p-4 text-sm sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Metadata label="接入通道" value={channelLabel(detail.channel)} />
|
||||
<Metadata label="运行模式" value={detail.runtimeMode} />
|
||||
<Metadata label="对话时长" value={durationLabel(detail)} />
|
||||
<Metadata label="状态" value={statusLabel(detail.status)} />
|
||||
)}
|
||||
{!detailLoading && detailError && (
|
||||
<div className="flex min-h-48 items-center justify-center text-sm text-destructive">
|
||||
{detailError}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ConversationTimeline detail={detail} />
|
||||
)}
|
||||
{detail && detailTab === "session" && (
|
||||
<SessionInfoPanel detail={detail} />
|
||||
)}
|
||||
{detail && detailTab === "analysis" && <AnalysisPanel />}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<main className="flex min-h-0 min-w-0 w-full flex-1 flex-col sm:w-1/2">
|
||||
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-hairline px-4 py-2.5 sm:px-5">
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
对话记录转写
|
||||
</div>
|
||||
{detail && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="gap-1 bg-surface-strong text-muted-foreground"
|
||||
>
|
||||
<MessageSquareText size={11} />
|
||||
{detail.messages.length}
|
||||
</Badge>
|
||||
{detail.messages.some((message) =>
|
||||
(message.artifacts ?? []).some(
|
||||
(item) => item.kind === "image",
|
||||
),
|
||||
) && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="gap-1 bg-surface-strong text-muted-foreground"
|
||||
>
|
||||
<ImageIcon size={11} />
|
||||
含图片
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="scrollbar-subtle min-h-0 flex-1 overflow-y-auto p-4 sm:p-5">
|
||||
{detailLoading && (
|
||||
<div className="flex min-h-48 items-center justify-center gap-2 text-muted-foreground">
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
正在加载对话内容
|
||||
</div>
|
||||
)}
|
||||
{!detailLoading && detailError && (
|
||||
<div className="flex min-h-48 items-center justify-center text-destructive">
|
||||
{detailError}
|
||||
</div>
|
||||
)}
|
||||
{detail && <TranscriptPanel detail={detail} />}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline">关闭</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
<HistoryPlaybackBar key={detail?.id ?? "empty"} detail={detail} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
@@ -746,26 +796,106 @@ function Metadata({ label, value }: { label: string; value: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
type TimelineEntry =
|
||||
| {
|
||||
kind: "message";
|
||||
timestamp: string;
|
||||
order: number;
|
||||
message: ConversationMessage;
|
||||
}
|
||||
| {
|
||||
kind: "trace";
|
||||
timestamp: string;
|
||||
order: number;
|
||||
event: TraceEvent;
|
||||
};
|
||||
function SessionInfoPanel({ detail }: { detail: ConversationDetail }) {
|
||||
const nodes = workflowNodes(detail);
|
||||
const trace = (detail.extra.workflowTrace ?? []).map(recordValue);
|
||||
const toolCount = trace.filter(
|
||||
(event) => event.event === "action_started" || event.event === "tool_started",
|
||||
).length;
|
||||
const transitionCount = trace.filter(
|
||||
(event) => event.event === "edge_selected",
|
||||
).length;
|
||||
const imageCount = detail.messages.reduce(
|
||||
(count, message) =>
|
||||
count +
|
||||
(message.artifacts ?? []).filter((item) => item.kind === "image").length,
|
||||
0,
|
||||
);
|
||||
|
||||
function timestampOrder(value: string): number {
|
||||
const parsed = Date.parse(value);
|
||||
return Number.isNaN(parsed) ? Number.MAX_SAFE_INTEGER : parsed;
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Metadata label="接入通道" value={channelLabel(detail.channel)} />
|
||||
<Metadata label="运行模式" value={detail.runtimeMode} />
|
||||
<Metadata label="对话时长" value={durationLabel(detail)} />
|
||||
<Metadata label="状态" value={statusLabel(detail.status)} />
|
||||
<Metadata label="开始时间" value={formatDate(detail.startedAt)} />
|
||||
<Metadata label="结束时间" value={formatDate(detail.endedAt)} />
|
||||
<Metadata label="会话 ID" value={detail.id} />
|
||||
<Metadata
|
||||
label="助手 ID"
|
||||
value={detail.assistantId || "调试会话"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-hairline pt-4">
|
||||
<div className="caption-label mb-2 text-muted-soft">统计</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="gap-1.5 bg-surface-strong text-muted-foreground"
|
||||
>
|
||||
<MessageSquareText size={12} />
|
||||
{detail.messages.length} 条对话
|
||||
</Badge>
|
||||
{imageCount > 0 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="gap-1.5 bg-surface-strong text-muted-foreground"
|
||||
>
|
||||
<ImageIcon size={12} />
|
||||
{imageCount} 张照片
|
||||
</Badge>
|
||||
)}
|
||||
{toolCount > 0 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="gap-1.5 bg-surface-strong text-muted-foreground"
|
||||
>
|
||||
<Wrench size={12} />
|
||||
{toolCount} 次工具
|
||||
</Badge>
|
||||
)}
|
||||
{transitionCount > 0 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="gap-1.5 bg-surface-strong text-muted-foreground"
|
||||
>
|
||||
<GitBranch size={12} />
|
||||
{transitionCount} 次转移
|
||||
</Badge>
|
||||
)}
|
||||
{nodes.size > 0 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="gap-1.5 bg-surface-strong text-muted-foreground"
|
||||
>
|
||||
工作流 {nodes.size} 节点
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConversationTimeline({ detail }: { detail: ConversationDetail }) {
|
||||
function AnalysisPanel() {
|
||||
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">
|
||||
<ChartLine size={18} />
|
||||
</div>
|
||||
<div className="mt-4 text-sm font-medium text-foreground">
|
||||
暂无分析结果
|
||||
</div>
|
||||
<p className="mt-1.5 max-w-xs text-xs leading-5 text-muted-foreground">
|
||||
通话后分析接入后,将在这里展示从对话中提取的关键信息字段。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TranscriptPanel({ detail }: { detail: ConversationDetail }) {
|
||||
const nodes = workflowNodes(detail);
|
||||
const trace = (detail.extra.workflowTrace ?? []).map(recordValue);
|
||||
const startedByInvocation = new Map<string, TraceEvent>();
|
||||
@@ -795,72 +925,135 @@ function ConversationTimeline({ detail }: { detail: ConversationDetail }) {
|
||||
a.order - b.order,
|
||||
);
|
||||
|
||||
const imageCount = detail.messages.reduce(
|
||||
(count, message) =>
|
||||
count + (message.artifacts ?? []).filter((item) => item.kind === "image").length,
|
||||
0,
|
||||
);
|
||||
const toolCount = trace.filter(
|
||||
(event) => event.event === "action_started" || event.event === "tool_started",
|
||||
).length;
|
||||
const transitionCount = trace.filter((event) => event.event === "edge_selected").length;
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<div className="flex min-h-48 items-center justify-center text-sm text-muted-foreground">
|
||||
本次会话没有产生可展示的记录
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="overflow-hidden rounded-xl border border-hairline bg-surface-strong/20">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-hairline px-4 py-3">
|
||||
<div className="text-sm font-medium">完整时间线</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="secondary" className="gap-1.5 bg-surface-strong text-muted-foreground">
|
||||
<MessageSquareText size={12} />
|
||||
{detail.messages.length} 条对话
|
||||
</Badge>
|
||||
{imageCount > 0 && (
|
||||
<Badge variant="secondary" className="gap-1.5 bg-surface-strong text-muted-foreground">
|
||||
<ImageIcon size={12} />
|
||||
{imageCount} 张照片
|
||||
</Badge>
|
||||
)}
|
||||
{toolCount > 0 && (
|
||||
<Badge variant="secondary" className="gap-1.5 bg-surface-strong text-muted-foreground">
|
||||
<Wrench size={12} />
|
||||
{toolCount} 次工具
|
||||
</Badge>
|
||||
)}
|
||||
{transitionCount > 0 && (
|
||||
<Badge variant="secondary" className="gap-1.5 bg-surface-strong text-muted-foreground">
|
||||
<GitBranch size={12} />
|
||||
{transitionCount} 次转移
|
||||
</Badge>
|
||||
)}
|
||||
<div className="space-y-4">
|
||||
{entries.map((entry, index) =>
|
||||
entry.kind === "message" ? (
|
||||
<TimelineMessage
|
||||
key={`message-${entry.message.id}`}
|
||||
message={entry.message}
|
||||
/>
|
||||
) : (
|
||||
<TimelineTrace
|
||||
key={`trace-${textValue(entry.event.eventId) || index}`}
|
||||
event={entry.event}
|
||||
nodes={nodes}
|
||||
startedEvent={startedByInvocation.get(
|
||||
textValue(recordValue(entry.event.outcome).invocationId) ||
|
||||
textValue(entry.event.invocationId),
|
||||
)}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatPlaybackClock(seconds: number): string {
|
||||
const total = Math.max(0, Math.floor(seconds));
|
||||
const m = Math.floor(total / 60);
|
||||
const s = total % 60;
|
||||
return `${m}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** 底部录音回放区(Mock:会话录音尚未接入后端时可演示进度)。 */
|
||||
function HistoryPlaybackBar({
|
||||
detail,
|
||||
}: {
|
||||
detail: ConversationDetail | null;
|
||||
}) {
|
||||
const durationSec = useMemo(() => {
|
||||
if (!detail?.endedAt) return 0;
|
||||
const ms =
|
||||
new Date(detail.endedAt).getTime() - new Date(detail.startedAt).getTime();
|
||||
return Number.isFinite(ms) ? Math.max(0, Math.round(ms / 1000)) : 0;
|
||||
}, [detail]);
|
||||
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const hasRecording = false;
|
||||
|
||||
useEffect(() => {
|
||||
if (!playing || durationSec <= 0) return;
|
||||
const id = window.setInterval(() => {
|
||||
setElapsed((current) => {
|
||||
if (current + 0.2 >= durationSec) {
|
||||
window.clearInterval(id);
|
||||
queueMicrotask(() => setPlaying(false));
|
||||
return durationSec;
|
||||
}
|
||||
return current + 0.2;
|
||||
});
|
||||
}, 200);
|
||||
return () => window.clearInterval(id);
|
||||
}, [playing, durationSec]);
|
||||
|
||||
const progress = durationSec > 0 ? Math.min(1, elapsed / durationSec) : 0;
|
||||
|
||||
return (
|
||||
<div className="shrink-0 border-t border-hairline bg-canvas-soft/40">
|
||||
<div className="flex items-center gap-3 px-4 py-2 sm:px-5">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="outline"
|
||||
className="shrink-0 border-hairline-strong"
|
||||
disabled={!detail || durationSec <= 0}
|
||||
aria-label={playing ? "暂停" : "播放"}
|
||||
onClick={() => {
|
||||
setPlaying((value) => !value);
|
||||
}}
|
||||
>
|
||||
{playing ? <Pause size={14} /> : <Play size={14} />}
|
||||
</Button>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-3 text-[11px] tabular-nums text-muted-soft">
|
||||
<span>{formatPlaybackClock(elapsed)}</span>
|
||||
<span className="truncate text-muted-foreground">
|
||||
{hasRecording ? "会话录音" : "录音回放(Mock,尚未接入录音文件)"}
|
||||
</span>
|
||||
<span>{formatPlaybackClock(durationSec)}</span>
|
||||
</div>
|
||||
<div className="mt-1 h-1 overflow-hidden rounded-full bg-surface-strong">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-[width] duration-150"
|
||||
style={{ width: `${progress * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 p-4 sm:p-5">
|
||||
{entries.map((entry, index) =>
|
||||
entry.kind === "message" ? (
|
||||
<TimelineMessage key={`message-${entry.message.id}`} message={entry.message} />
|
||||
) : (
|
||||
<TimelineTrace
|
||||
key={`trace-${textValue(entry.event.eventId) || index}`}
|
||||
event={entry.event}
|
||||
nodes={nodes}
|
||||
startedEvent={startedByInvocation.get(
|
||||
textValue(recordValue(entry.event.outcome).invocationId) ||
|
||||
textValue(entry.event.invocationId),
|
||||
)}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
{entries.length === 0 && (
|
||||
<div className="py-12 text-center text-muted-foreground">
|
||||
本次会话没有产生可展示的记录
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type TimelineEntry =
|
||||
| {
|
||||
kind: "message";
|
||||
timestamp: string;
|
||||
order: number;
|
||||
message: ConversationMessage;
|
||||
}
|
||||
| {
|
||||
kind: "trace";
|
||||
timestamp: string;
|
||||
order: number;
|
||||
event: TraceEvent;
|
||||
};
|
||||
|
||||
function timestampOrder(value: string): number {
|
||||
const parsed = Date.parse(value);
|
||||
return Number.isNaN(parsed) ? Number.MAX_SAFE_INTEGER : parsed;
|
||||
}
|
||||
|
||||
function TimelineMessage({ message }: { message: ConversationMessage }) {
|
||||
const isUser = message.role === "user";
|
||||
const images = (message.artifacts ?? []).filter((item) => item.kind === "image");
|
||||
|
||||
423
frontend/src/components/pages/TemplateLibraryPage.tsx
Normal file
423
frontend/src/components/pages/TemplateLibraryPage.tsx
Normal file
@@ -0,0 +1,423 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Bot,
|
||||
ChevronLeft,
|
||||
LayoutTemplate,
|
||||
Loader2,
|
||||
MessageSquareText,
|
||||
Rocket,
|
||||
Sparkles,
|
||||
Workflow,
|
||||
} from "lucide-react";
|
||||
|
||||
import { SectionCard } from "@/components/editor/section-card";
|
||||
import { ListPageSection } from "@/components/layout/list-page-layout";
|
||||
import { TopbarPortal } from "@/components/layout/topbar-portal";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FilterPills } from "@/components/ui/filter-pills";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { SearchInput } from "@/components/ui/search-input";
|
||||
import {
|
||||
SelectionOptionCard,
|
||||
selectionOptionGridThreeColumnClassName,
|
||||
} from "@/components/ui/selection-option-card";
|
||||
import {
|
||||
assistantTemplates,
|
||||
type AssistantTemplate,
|
||||
type TemplateBuildMethod,
|
||||
} from "@/data/assistant-templates";
|
||||
import {
|
||||
assistantsApi,
|
||||
type AssistantType as ApiAssistantType,
|
||||
type AssistantUpsert,
|
||||
type KnowledgeRetrievalConfig,
|
||||
} from "@/lib/api";
|
||||
import { defaultTurnConfig } from "@/lib/turn-config";
|
||||
|
||||
function defaultKnowledgeRetrievalConfig(): KnowledgeRetrievalConfig {
|
||||
return {
|
||||
mode: "automatic",
|
||||
topN: 5,
|
||||
scoreThreshold: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const TEMPLATE_TYPE_FILTERS = ["全部", "Prompt", "Workflow"] as const;
|
||||
|
||||
type TemplateTypeFilter = (typeof TEMPLATE_TYPE_FILTERS)[number];
|
||||
|
||||
function templateTypeLabel(buildMethod: TemplateBuildMethod): "Prompt" | "Workflow" {
|
||||
return buildMethod === "工作流" ? "Workflow" : "Prompt";
|
||||
}
|
||||
|
||||
function matchesTypeFilter(
|
||||
buildMethod: TemplateBuildMethod,
|
||||
filter: TemplateTypeFilter,
|
||||
): boolean {
|
||||
if (filter === "全部") return true;
|
||||
return templateTypeLabel(buildMethod) === filter;
|
||||
}
|
||||
|
||||
const buildMethodIcon: Record<
|
||||
TemplateBuildMethod,
|
||||
React.ComponentType<{ size?: number; className?: string }>
|
||||
> = {
|
||||
提示词: MessageSquareText,
|
||||
工作流: Workflow,
|
||||
};
|
||||
|
||||
function templateAssistantType(
|
||||
buildMethod: TemplateBuildMethod,
|
||||
): ApiAssistantType {
|
||||
return buildMethod === "工作流" ? "workflow" : "prompt";
|
||||
}
|
||||
|
||||
function baseUpsertFromTemplate(
|
||||
name: string,
|
||||
template: AssistantTemplate,
|
||||
): AssistantUpsert {
|
||||
return {
|
||||
name: name.trim(),
|
||||
type: templateAssistantType(template.buildMethod),
|
||||
runtimeMode: "pipeline",
|
||||
greeting: template.greeting,
|
||||
enableInterrupt: true,
|
||||
turnConfig: defaultTurnConfig(),
|
||||
startup: {
|
||||
executionMode: "sequential",
|
||||
openingMode: "interruptible",
|
||||
entryMode: "wait_user",
|
||||
actions: [],
|
||||
openingMessage: null,
|
||||
},
|
||||
visionEnabled: false,
|
||||
visionModelResourceId: null,
|
||||
modelResourceIds: {},
|
||||
knowledgeBaseId: null,
|
||||
knowledgeRetrievalConfig: defaultKnowledgeRetrievalConfig(),
|
||||
toolIds: [],
|
||||
prompt: template.prompt,
|
||||
dynamicVariableDefinitions: {},
|
||||
apiUrl: "",
|
||||
apiKey: "",
|
||||
appId: "",
|
||||
graph: {},
|
||||
};
|
||||
}
|
||||
|
||||
function TemplatePromptPreview({ template }: { template: AssistantTemplate }) {
|
||||
const BuildIcon = buildMethodIcon[template.buildMethod];
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<SectionCard
|
||||
icon={<MessageSquareText size={15} />}
|
||||
title="提示词"
|
||||
description="描述助手的角色、能力和回答要求"
|
||||
>
|
||||
<p className="whitespace-pre-wrap rounded-xl border border-hairline bg-canvas-soft px-3.5 py-3 text-sm leading-6 text-muted-foreground">
|
||||
{template.prompt}
|
||||
</p>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
icon={<Bot size={15} />}
|
||||
title="开场白"
|
||||
description="助手与用户首次对话时的开场语"
|
||||
>
|
||||
<p className="rounded-xl border border-hairline bg-canvas-soft px-3.5 py-3 text-sm leading-6 text-foreground">
|
||||
{template.greeting}
|
||||
</p>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
icon={<Sparkles size={15} />}
|
||||
title="模版说明"
|
||||
description="构建方式、标签与能力亮点"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge className="h-6 gap-1.5 bg-surface-strong px-3 text-muted-foreground">
|
||||
<BuildIcon size={13} />
|
||||
{templateTypeLabel(template.buildMethod)}
|
||||
</Badge>
|
||||
{template.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded-full bg-canvas-soft px-2.5 py-0.5 text-[11px] text-muted-foreground"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-sm leading-6 text-muted-foreground">
|
||||
{template.description}
|
||||
</p>
|
||||
<ul className="space-y-2 border-t border-hairline pt-3">
|
||||
{template.highlights.map((item) => (
|
||||
<li
|
||||
key={item}
|
||||
className="flex gap-2 text-sm leading-6 text-foreground"
|
||||
>
|
||||
<span className="mt-2 h-1 w-1 shrink-0 rounded-full bg-muted-foreground" />
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</SectionCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplateWorkflowPreview() {
|
||||
return (
|
||||
<div
|
||||
className="min-h-0 flex-1 w-full bg-canvas-soft"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"radial-gradient(circle, color-mix(in srgb, var(--hairline) 80%, transparent) 1px, transparent 1px)",
|
||||
backgroundSize: "24px 24px",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplatePreviewHeader({
|
||||
selectedTemplate,
|
||||
}: {
|
||||
selectedTemplate: AssistantTemplate | null;
|
||||
}) {
|
||||
const previewHint = selectedTemplate
|
||||
? selectedTemplate.buildMethod === "工作流"
|
||||
? "工作流模版的节点编排预览(占位画布)。"
|
||||
: "查看模版预置的提示词、开场白与说明。"
|
||||
: "从左侧选择模版后,在此预览预置配置。";
|
||||
|
||||
return (
|
||||
<div className="shrink-0 border-b border-hairline px-4 py-4 sm:px-6 lg:px-8">
|
||||
<div className="text-sm font-medium text-foreground">配置预览</div>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
||||
{previewHint}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplateEmptyPanel() {
|
||||
return (
|
||||
<div className="flex min-h-[320px] flex-col items-center justify-center rounded-2xl border border-dashed border-hairline-strong bg-canvas-soft px-6 py-16 text-center">
|
||||
<LayoutTemplate size={32} className="text-muted-soft" />
|
||||
<div className="mt-4 text-sm font-medium text-foreground">
|
||||
请从左侧选择一个模版
|
||||
</div>
|
||||
<p className="mt-1 max-w-sm text-xs leading-5 text-muted-foreground">
|
||||
选中后,右侧将展示该模版的配置预览。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type TemplateLibraryPageProps = {
|
||||
initialTemplateId?: string;
|
||||
};
|
||||
|
||||
export function TemplateLibraryPage({
|
||||
initialTemplateId,
|
||||
}: TemplateLibraryPageProps = {}) {
|
||||
const router = useRouter();
|
||||
const [assistantName, setAssistantName] = useState("");
|
||||
const [typeFilter, setTypeFilter] = useState<TemplateTypeFilter>("全部");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedId, setSelectedId] = useState<string | null>(
|
||||
initialTemplateId ?? null,
|
||||
);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
|
||||
const filteredTemplates = useMemo(() => {
|
||||
const keyword = searchQuery.trim().toLowerCase();
|
||||
return assistantTemplates.filter((template) => {
|
||||
const matchesType = matchesTypeFilter(template.buildMethod, typeFilter);
|
||||
const matchesSearch =
|
||||
keyword === "" ||
|
||||
template.name.toLowerCase().includes(keyword) ||
|
||||
template.description.toLowerCase().includes(keyword) ||
|
||||
template.tags.some((tag) => tag.toLowerCase().includes(keyword));
|
||||
return matchesType && matchesSearch;
|
||||
});
|
||||
}, [typeFilter, searchQuery]);
|
||||
|
||||
const selectedTemplate = useMemo(
|
||||
() => assistantTemplates.find((template) => template.id === selectedId) ?? null,
|
||||
[selectedId],
|
||||
);
|
||||
|
||||
const canCreate = Boolean(
|
||||
assistantName.trim() && selectedTemplate && !creating,
|
||||
);
|
||||
|
||||
async function handleCreateFromTemplate() {
|
||||
if (!canCreate || !selectedTemplate) return;
|
||||
|
||||
setCreating(true);
|
||||
setCreateError(null);
|
||||
try {
|
||||
const created = await assistantsApi.create(
|
||||
baseUpsertFromTemplate(assistantName, selectedTemplate),
|
||||
);
|
||||
router.push(`/assistants/${created.id}`);
|
||||
} catch (error) {
|
||||
setCreateError(error instanceof Error ? error.message : "创建失败");
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<TopbarPortal>
|
||||
<div className="flex h-full min-w-0 flex-1 items-center sm:-ml-2 lg:-ml-4">
|
||||
<h1 className="font-display text-2xl text-ink">模版库</h1>
|
||||
</div>
|
||||
</TopbarPortal>
|
||||
|
||||
<div
|
||||
data-app-content="full-bleed"
|
||||
className="flex h-full min-h-0 overflow-hidden bg-background"
|
||||
>
|
||||
<aside className="flex min-w-0 w-1/2 shrink-0 flex-col border-r border-hairline bg-background">
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-hidden px-6 py-5 sm:px-8 sm:py-6">
|
||||
<div className="flex shrink-0 items-center justify-between gap-4">
|
||||
<p className="min-w-0 text-sm leading-6 whitespace-nowrap text-muted-foreground">
|
||||
先为助手取个名字,再选择模版。确认后将使用模版创建并进入编辑页。
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="shrink-0 gap-2 border-hairline-strong text-muted-foreground hover:text-foreground"
|
||||
onClick={() => router.push("/assistants")}
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
返回列表
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ListPageSection>
|
||||
<label className="block">
|
||||
<div className="mb-2 text-sm font-medium text-foreground">
|
||||
助手名称
|
||||
</div>
|
||||
<Input
|
||||
value={assistantName}
|
||||
autoFocus
|
||||
onChange={(event) => setAssistantName(event.target.value)}
|
||||
placeholder="请输入助手名称"
|
||||
className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
|
||||
/>
|
||||
</label>
|
||||
</ListPageSection>
|
||||
|
||||
<section className="flex min-h-0 flex-1 flex-col gap-3">
|
||||
<div className="text-sm font-medium text-foreground">选择模版</div>
|
||||
<SearchInput
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
placeholder="搜索模版..."
|
||||
/>
|
||||
<FilterPills
|
||||
options={TEMPLATE_TYPE_FILTERS}
|
||||
value={typeFilter}
|
||||
onChange={setTypeFilter}
|
||||
/>
|
||||
|
||||
<div className="scrollbar-subtle min-h-0 flex-1 overflow-y-auto pr-1">
|
||||
{filteredTemplates.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-hairline-strong bg-canvas-soft px-4 py-12 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={selectionOptionGridThreeColumnClassName}>
|
||||
{filteredTemplates.map((template) => {
|
||||
const BuildIcon = buildMethodIcon[template.buildMethod];
|
||||
|
||||
return (
|
||||
<SelectionOptionCard
|
||||
key={template.id}
|
||||
selected={template.id === selectedId}
|
||||
icon={<BuildIcon size={20} />}
|
||||
title={template.name}
|
||||
description={template.description}
|
||||
descriptionClassName="line-clamp-2"
|
||||
onClick={() => setSelectedId(template.id)}
|
||||
unselectedTrailing={
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-6 bg-surface-strong px-3 text-xs text-muted-foreground"
|
||||
>
|
||||
{templateTypeLabel(template.buildMethod)}
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="flex shrink-0 items-center justify-end gap-3">
|
||||
{createError && (
|
||||
<span className="mr-auto text-xs text-destructive">
|
||||
{createError}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-hairline-strong text-muted-foreground hover:text-foreground"
|
||||
disabled={creating}
|
||||
onClick={() => router.push("/assistants")}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
className="gap-2"
|
||||
disabled={!canCreate}
|
||||
onClick={() => void handleCreateFromTemplate()}
|
||||
>
|
||||
{creating ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<Rocket size={16} />
|
||||
)}
|
||||
使用此模版
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="flex min-h-0 min-w-0 w-1/2 flex-col overflow-hidden bg-background">
|
||||
<TemplatePreviewHeader selectedTemplate={selectedTemplate} />
|
||||
{selectedTemplate?.buildMethod === "工作流" ? (
|
||||
<TemplateWorkflowPreview />
|
||||
) : (
|
||||
<div className="scrollbar-subtle min-h-0 flex-1 overflow-y-auto overscroll-none px-4 pb-6 pt-3 sm:px-6 sm:pb-8 lg:px-8 lg:pb-10">
|
||||
<div className="mx-auto w-full max-w-7xl">
|
||||
{selectedTemplate ? (
|
||||
<TemplatePromptPreview template={selectedTemplate} />
|
||||
) : (
|
||||
<TemplateEmptyPanel />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
76
frontend/src/components/ui/selection-option-card.tsx
Normal file
76
frontend/src/components/ui/selection-option-card.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Create-assistant page: 4 columns within max-w-[1180px] content */
|
||||
export const selectionOptionGridClassName =
|
||||
"grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4";
|
||||
|
||||
/** Template library left panel: 3 columns within a half-width split */
|
||||
export const selectionOptionGridThreeColumnClassName =
|
||||
"grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3";
|
||||
|
||||
export type SelectionOptionCardProps = {
|
||||
selected: boolean;
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
onClick: () => void;
|
||||
/** Top-right content when not selected. Omit to show nothing. */
|
||||
unselectedTrailing?: React.ReactNode;
|
||||
descriptionClassName?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function SelectionOptionCard({
|
||||
selected,
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
onClick,
|
||||
unselectedTrailing,
|
||||
descriptionClassName,
|
||||
className,
|
||||
}: SelectionOptionCardProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"group relative flex w-full flex-col gap-4 rounded-2xl border bg-card p-5 text-left transition-colors",
|
||||
selected
|
||||
? "border-primary ring-1 ring-primary"
|
||||
: "border-hairline hover:border-hairline-strong",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-surface-strong text-foreground">
|
||||
{icon}
|
||||
</div>
|
||||
|
||||
{selected ? (
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-primary text-primary-foreground">
|
||||
<Check size={14} />
|
||||
</span>
|
||||
) : (
|
||||
unselectedTrailing ?? null
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-base font-medium text-foreground">{title}</div>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-1.5 text-sm leading-6 text-muted-foreground",
|
||||
descriptionClassName,
|
||||
)}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
299
frontend/src/data/assistant-templates.ts
Normal file
299
frontend/src/data/assistant-templates.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
export type TemplateBuildMethod = "提示词" | "工作流";
|
||||
|
||||
export type TemplatePreviewMessage = {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type AssistantTemplate = {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
buildMethod: TemplateBuildMethod;
|
||||
greeting: string;
|
||||
prompt: string;
|
||||
highlights: string[];
|
||||
previewMessages: TemplatePreviewMessage[];
|
||||
};
|
||||
|
||||
export const assistantTemplates: AssistantTemplate[] = [
|
||||
{
|
||||
id: "tpl-customer-service",
|
||||
name: "电商售后客服",
|
||||
category: "客服",
|
||||
description:
|
||||
"处理退换货、物流查询与订单问题,语气耐心专业,适合接入商品知识库。",
|
||||
tags: ["退换货", "订单查询", "Pipeline"],
|
||||
buildMethod: "提示词",
|
||||
greeting: "您好,我是您的专属售后助手,请问需要查询订单还是办理退换货?",
|
||||
prompt:
|
||||
"你是一名电商平台的售后客服助手。优先确认用户订单号,再依据平台政策解答退换货、物流与发票问题。回答简洁、步骤清晰,无法处理时引导用户转人工。",
|
||||
highlights: [
|
||||
"支持订单号引导与常见问题分流",
|
||||
"默认 Pipeline 模式,可挂载商品知识库",
|
||||
"开场白可直接询问订单或退换货意图",
|
||||
],
|
||||
previewMessages: [
|
||||
{ role: "assistant", content: "您好,我是您的专属售后助手,请问需要查询订单还是办理退换货?" },
|
||||
{ role: "user", content: "我昨天下的单还没发货,订单号是 20240806-8831。" },
|
||||
{
|
||||
role: "assistant",
|
||||
content:
|
||||
"已查到订单 20240806-8831,当前状态为「仓库拣货中」,预计今晚 22:00 前发出。需要我帮您修改收货地址吗?",
|
||||
},
|
||||
{ role: "user", content: "不用改地址,能加急吗?" },
|
||||
{
|
||||
role: "assistant",
|
||||
content:
|
||||
"可以为您标记加急出库,成功后物流单号会通过短信通知。还有其他问题我可以帮您处理吗?",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "tpl-sales-consultant",
|
||||
name: "B2B 销售顾问",
|
||||
category: "销售",
|
||||
description:
|
||||
"面向企业客户的方案咨询助手,擅长需求挖掘、产品亮点介绍与预约演示。",
|
||||
tags: ["需求挖掘", "方案介绍", "预约演示"],
|
||||
buildMethod: "提示词",
|
||||
greeting: "您好,我是产品顾问小云。可以先简单说说您团队目前遇到的业务场景吗?",
|
||||
prompt:
|
||||
"你是 B2B SaaS 产品的销售顾问。通过 2-3 个开放式问题了解客户规模、现有流程与痛点,再匹配产品能力。避免过度承诺,适时邀请预约产品演示。",
|
||||
highlights: [
|
||||
"结构化需求提问,避免一上来报价",
|
||||
"适合 Realtime 或 Pipeline 语音对话",
|
||||
"可扩展 CRM 工具调用(Mock)",
|
||||
],
|
||||
previewMessages: [
|
||||
{ role: "assistant", content: "您好,我是产品顾问小云。可以先简单说说您团队目前遇到的业务场景吗?" },
|
||||
{ role: "user", content: "我们客服团队大概 40 人,想降低重复咨询的处理时间。" },
|
||||
{
|
||||
role: "assistant",
|
||||
content:
|
||||
"了解。请问重复咨询主要集中在哪些主题?例如物流、账号还是产品使用?另外目前是否有知识库或标准话术?",
|
||||
},
|
||||
{ role: "user", content: "主要是产品使用和售后政策,知识库比较零散。" },
|
||||
{
|
||||
role: "assistant",
|
||||
content:
|
||||
"这类场景很适合用 AI 助手先做首轮解答并检索知识库。我可以安排 30 分钟演示,带您看同类客户的落地方式,您看本周四下午是否方便?",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "tpl-interview-coach",
|
||||
name: "面试模拟教练",
|
||||
category: "培训",
|
||||
description:
|
||||
"模拟技术/产品面试场景,按 STAR 法则追问并给出反馈,适合培训与练习。",
|
||||
tags: ["STAR 追问", "反馈点评", "角色扮演"],
|
||||
buildMethod: "工作流",
|
||||
greeting: "欢迎参加模拟面试。请先告诉我目标岗位,我们从自我介绍开始。",
|
||||
prompt:
|
||||
"你是一名严谨的面试官教练。每次只提一个问题,等待候选人回答后再追问细节。回答结束后给出 1 条优点和 1 条改进建议,保持鼓励但不空泛。",
|
||||
highlights: [
|
||||
"工作流编排:开场 → 提问 → 点评 → 下一题",
|
||||
"适合视频通话中的连续追问",
|
||||
"可按岗位切换题库(Mock)",
|
||||
],
|
||||
previewMessages: [
|
||||
{ role: "assistant", content: "欢迎参加模拟面试。请先告诉我目标岗位,我们从自我介绍开始。" },
|
||||
{ role: "user", content: "前端工程师,偏 React 和实时通信。" },
|
||||
{
|
||||
role: "assistant",
|
||||
content:
|
||||
"好的。请用 90 秒介绍一个你负责过的实时通信项目,重点说明你的职责与遇到的最大技术挑战。",
|
||||
},
|
||||
{ role: "user", content: "做过 WebRTC 客服系统,我负责信令层和重连策略……" },
|
||||
{
|
||||
role: "assistant",
|
||||
content:
|
||||
"回答结构清晰。建议补充量化结果,例如延迟或掉线率改善数据。下一个问题:如果 ICE 连接频繁失败,你会如何排查?",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "tpl-health-triage",
|
||||
name: "健康咨询分诊",
|
||||
category: "医疗",
|
||||
description:
|
||||
"收集症状与基本信息,给出就医建议与注意事项,不替代医生诊断。",
|
||||
tags: ["分诊引导", "合规提示", "预约转诊"],
|
||||
buildMethod: "工作流",
|
||||
greeting: "您好,我是健康咨询助手。我会帮您梳理症状,但无法替代医生诊断。请问哪里不舒服?",
|
||||
prompt:
|
||||
"你是医疗机构的预问诊分诊助手。依次了解主诉、持续时间、严重程度与既往史。给出就医 urgency 建议与注意事项,遇到危急描述立即建议拨打 120 或前往急诊。",
|
||||
highlights: [
|
||||
"内置合规免责声明",
|
||||
"工作流分支:轻症建议 / 建议门诊 / 紧急提醒",
|
||||
"可对接预约 API(Mock)",
|
||||
],
|
||||
previewMessages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: "您好,我是健康咨询助手。我会帮您梳理症状,但无法替代医生诊断。请问哪里不舒服?",
|
||||
},
|
||||
{ role: "user", content: "头痛两天了,还有点低烧。" },
|
||||
{
|
||||
role: "assistant",
|
||||
content:
|
||||
"请问体温大概多少?头痛是持续性的还是阵发性的?有没有颈部僵硬、呕吐或意识模糊?",
|
||||
},
|
||||
{ role: "user", content: "37.8 度左右,一阵一阵的,没有其他那些症状。" },
|
||||
{
|
||||
role: "assistant",
|
||||
content:
|
||||
"目前看不属于紧急危象。建议今日到社区门诊或互联网医院进一步评估,期间多休息、补水,若体温超过 38.5℃ 或症状加重请及时就医。",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "tpl-hotel-concierge",
|
||||
name: "酒店礼宾助手",
|
||||
category: "通用",
|
||||
description:
|
||||
"解答入住、设施、周边推荐与客房服务请求,语气温暖,适合 hospitality 场景。",
|
||||
tags: ["入住咨询", "客房服务", "周边推荐"],
|
||||
buildMethod: "提示词",
|
||||
greeting: "欢迎光临。我是礼宾助手,可以帮您查询入住信息、设施开放时间与周边推荐。",
|
||||
prompt:
|
||||
"你是高端酒店的礼宾助手。优先确认客人姓名或房间号(若涉及隐私可跳过),再回答设施、餐饮、交通与本地推荐。语气礼貌、简洁,主动提供下一步帮助。",
|
||||
highlights: [
|
||||
"适合视频大堂或移动端呼叫",
|
||||
"可挂载酒店 FAQ 知识库",
|
||||
"支持中英文切换(Mock)",
|
||||
],
|
||||
previewMessages: [
|
||||
{ role: "assistant", content: "欢迎光临。我是礼宾助手,可以帮您查询入住信息、设施开放时间与周边推荐。" },
|
||||
{ role: "user", content: "健身房几点开?早餐到几点?" },
|
||||
{
|
||||
role: "assistant",
|
||||
content:
|
||||
"健身房每日 6:00–22:00 开放,位于 3 层。自助早餐 6:30–10:30,在一层「云间」餐厅。需要我帮您预约私教或打包外带早餐吗?",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "tpl-onboarding-guide",
|
||||
name: "新员工入职向导",
|
||||
category: "培训",
|
||||
description:
|
||||
"解答公司制度、IT 账号、办公地点与首日流程,降低 HR 重复答疑压力。",
|
||||
tags: ["入职流程", "IT 支持", "制度问答"],
|
||||
buildMethod: "提示词",
|
||||
greeting: "欢迎加入团队!我是入职向导,可以帮您了解首日安排、账号开通和常用制度。",
|
||||
prompt:
|
||||
"你是企业新员工入职向导。根据员工部门与入职日期,介绍报到流程、IT 账号、考勤与福利政策。不确定的信息引导联系 HR 或 IT 服务台,不要编造政策细节。",
|
||||
highlights: [
|
||||
"可绑定员工手册知识库",
|
||||
"支持动态变量 {{department}}、{{start_date}}",
|
||||
"适合 Pipeline + 知识检索",
|
||||
],
|
||||
previewMessages: [
|
||||
{ role: "assistant", content: "欢迎加入团队!我是入职向导,可以帮您了解首日安排、账号开通和常用制度。" },
|
||||
{ role: "user", content: "明天第一天上班,我需要带什么?几点到?" },
|
||||
{
|
||||
role: "assistant",
|
||||
content:
|
||||
"请携带身份证原件与学历复印件,9:30 到总部 A 座 18 层 HR 前台报到。IT 会在报到后 1 小时内开通邮箱与 VPN,建议提前下载企业通讯 App。",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "tpl-ticket-routing",
|
||||
name: "工单智能分流",
|
||||
category: "客服",
|
||||
description:
|
||||
"按问题类型、优先级与 SLA 将用户诉求路由到不同处理分支,适合复杂售后场景。",
|
||||
tags: ["条件分支", "工单创建", "SLA"],
|
||||
buildMethod: "工作流",
|
||||
greeting: "您好,我是工单助手。请简单描述您遇到的问题,我会帮您归类并创建工单。",
|
||||
prompt:
|
||||
"你是工单分流助手。先识别问题类别(账号、支付、物流、产品故障),再判断紧急程度,引导用户补充必要信息并说明预计处理时效。",
|
||||
highlights: [
|
||||
"多分支路由:咨询 / 投诉 / 紧急故障",
|
||||
"可对接工单系统创建 API(Mock)",
|
||||
"支持转人工节点",
|
||||
],
|
||||
previewMessages: [],
|
||||
},
|
||||
{
|
||||
id: "tpl-insurance-claim",
|
||||
name: "保险理赔引导",
|
||||
category: "通用",
|
||||
description:
|
||||
"分步骤收集事故信息、材料清单与理赔进度查询,减少重复说明。",
|
||||
tags: ["材料清单", "进度查询", "分步表单"],
|
||||
buildMethod: "工作流",
|
||||
greeting: "您好,我是理赔引导助手。请问您需要报案、补充材料,还是查询进度?",
|
||||
prompt:
|
||||
"你是保险理赔引导助手。根据用户意图进入不同流程:报案需收集时间地点与损失描述;补材料需核对清单;查进度需验证保单号后四位。",
|
||||
highlights: [
|
||||
"报案 / 补材 / 查进度三条主路径",
|
||||
"节点化收集结构化字段",
|
||||
"合规话术与隐私字段脱敏",
|
||||
],
|
||||
previewMessages: [],
|
||||
},
|
||||
{
|
||||
id: "tpl-course-sales",
|
||||
name: "课程咨询转化",
|
||||
category: "销售",
|
||||
description:
|
||||
"从课程介绍、试听预约到优惠说明的多节点销售流程,适合教培机构。",
|
||||
tags: ["试听预约", "优惠说明", "线索留资"],
|
||||
buildMethod: "工作流",
|
||||
greeting: "你好,我是课程顾问。想了解哪门课程?我可以介绍大纲并帮你预约试听。",
|
||||
prompt:
|
||||
"你是教培机构课程顾问。先确认目标课程与用户基础,再介绍班型与价格区间,适时引导留资或预约试听,避免硬性推销。",
|
||||
highlights: [
|
||||
"课程匹配 → 试听预约 → 优惠说明",
|
||||
"支持留资节点写入 CRM(Mock)",
|
||||
"可插入固定话术节点",
|
||||
],
|
||||
previewMessages: [],
|
||||
},
|
||||
{
|
||||
id: "tpl-device-repair",
|
||||
name: "设备故障报修",
|
||||
category: "客服",
|
||||
description:
|
||||
"引导用户描述设备型号与故障现象,自动判断远程指导或上门维修路径。",
|
||||
tags: ["故障诊断", "上门预约", "远程指导"],
|
||||
buildMethod: "工作流",
|
||||
greeting: "您好,我是设备报修助手。请告诉我设备型号和出现的故障现象。",
|
||||
prompt:
|
||||
"你是设备售后报修助手。收集型号、购买日期、故障代码或现象,判断可否远程排查;需上门时收集地址与可预约时间段。",
|
||||
highlights: [
|
||||
"远程指导 / 上门维修双路径",
|
||||
"故障现象结构化采集",
|
||||
"可对接预约系统(Mock)",
|
||||
],
|
||||
previewMessages: [],
|
||||
},
|
||||
{
|
||||
id: "tpl-compliance-review",
|
||||
name: "合规话术审查",
|
||||
category: "培训",
|
||||
description:
|
||||
"按合规检查清单逐条核对销售话术,输出风险点与改写建议。",
|
||||
tags: ["合规检查", "话术改写", "审核流程"],
|
||||
buildMethod: "工作流",
|
||||
greeting: "请提交待审查的话术或录音转写内容,我会按合规清单逐条检查。",
|
||||
prompt:
|
||||
"你是金融销售合规审查助手。按禁止承诺收益、风险提示、适当性匹配等维度检查输入内容,逐条标注风险并给出合规改写建议。",
|
||||
highlights: [
|
||||
"清单式多节点审查",
|
||||
"输出风险等级与改写建议",
|
||||
"适合质检与培训场景",
|
||||
],
|
||||
previewMessages: [],
|
||||
},
|
||||
];
|
||||
|
||||
export function getAssistantTemplate(id: string): AssistantTemplate | undefined {
|
||||
return assistantTemplates.find((template) => template.id === id);
|
||||
}
|
||||
Reference in New Issue
Block a user