Compare commits

...

5 Commits

Author SHA1 Message Date
Xin Wang
485ad623d4 feat(frontend): improve history detail and model list UX
Open model resources via row click, restyle history detail with
session/analysis panels and mock playback, and theme scrollbars
globally with navy light/dark tokens.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 00:25:58 +08:00
Xin Wang
87b6392eea style(frontend): enhance button styling in AssistantPage
Update button styles in AssistantPage to include minimum width and padding for better layout consistency across different screen sizes.
2026-08-06 23:19:00 +08:00
Xin Wang
b51b768c29 feat(frontend): add template library and extract create-assistant page
Introduce a split-view template library for creating assistants from presets, share selection cards between create and template flows, and move the choose step out of AssistantPage into CreateAssistantPage.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 23:15:06 +08:00
Xin Wang
723ee84925 feat(frontend): add assistant delete menu to prompt editor topbar
Place a more-actions dropdown beside the assistant ID with confirmed delete that returns to the assistant list after removal.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 17:08:40 +08:00
Xin Wang
b6e2ac5765 feat(frontend): add mock post-call analysis and webhook sections to prompt editor
Introduce Analysis and Webhook section cards with local mock state so operators can configure post-call extraction fields and delivery before backend wiring.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 16:57:31 +08:00
14 changed files with 1956 additions and 368 deletions

View File

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

View 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)}`);
}

View 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} />;
}

View File

@@ -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
);
}
/* 手机通话页「开始通话」引导光晕:柔和向外扩散的涟漪环 */

View File

@@ -0,0 +1,386 @@
"use client";
import { useState } from "react";
import { Loader2, Plus, Send, Trash2 } from "lucide-react";
import { ResourceSelectField, ToggleRow } from "@/components/assistant-editor/editor-controls";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
export type AnalysisFieldType =
| "string"
| "boolean"
| "integer"
| "number"
| "enum";
export type AnalysisField = {
id: string;
name: string;
type: AnalysisFieldType;
description: string;
enumValues: string[];
};
export type AnalysisConfig = {
enabled: boolean;
modelResourceId: string;
fields: AnalysisField[];
};
export type WebhookConfig = {
url: string;
secret: string;
};
export function defaultAnalysisConfig(): AnalysisConfig {
return {
enabled: false,
modelResourceId: "",
fields: [],
};
}
export function defaultWebhookConfig(): WebhookConfig {
return {
url: "",
secret: "",
};
}
const FIELD_TYPE_OPTIONS: Array<{
value: AnalysisFieldType;
label: string;
}> = [
{ value: "string", label: "string" },
{ value: "boolean", label: "boolean" },
{ value: "integer", label: "integer" },
{ value: "number", label: "number" },
{ value: "enum", label: "enum" },
];
function createField(): AnalysisField {
return {
id: crypto.randomUUID(),
name: "",
type: "string",
description: "",
enumValues: [],
};
}
function buildMockPayload(fields: AnalysisField[]) {
const extracted: Record<string, unknown> = {};
for (const field of fields) {
if (!field.name.trim()) continue;
switch (field.type) {
case "boolean":
extracted[field.name] = true;
break;
case "integer":
extracted[field.name] = 1;
break;
case "number":
extracted[field.name] = 1.5;
break;
case "enum":
extracted[field.name] = field.enumValues[0] ?? "option_a";
break;
default:
extracted[field.name] = "示例值";
}
}
return {
event: "call.analysis.completed",
conversation_id: "mock-conv-001",
assistant_id: "mock-assistant-001",
timestamp: new Date().toISOString(),
analysis: extracted,
};
}
type AnalysisConfigEditorProps = {
config: AnalysisConfig;
onChange: (config: AnalysisConfig) => void;
modelOptions: Array<{ value: string; label: string }>;
};
export function AnalysisConfigEditor({
config,
onChange,
modelOptions,
}: AnalysisConfigEditorProps) {
function patch(partial: Partial<AnalysisConfig>) {
onChange({ ...config, ...partial });
}
function updateField(id: string, partial: Partial<AnalysisField>) {
patch({
fields: config.fields.map((field) =>
field.id === id ? { ...field, ...partial } : field,
),
});
}
function removeField(id: string) {
patch({ fields: config.fields.filter((field) => field.id !== id) });
}
function addField() {
patch({ fields: [...config.fields, createField()] });
}
return (
<div className="space-y-3">
<ToggleRow
title="通话后分析"
hint="通话结束后,使用所选模型从对话中提取关键信息。"
checked={config.enabled}
onChange={(enabled) => patch({ enabled })}
/>
{config.enabled && (
<>
<ResourceSelectField
label="分析模型"
value={config.modelResourceId}
onChange={(modelResourceId) => patch({ modelResourceId })}
options={modelOptions}
noneLabel="请选择"
/>
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium text-foreground">
</div>
<p className="mt-0.5 text-xs leading-5 text-muted-foreground">
</p>
</div>
<Button
type="button"
variant="outline"
size="icon-sm"
className="shrink-0 border-hairline-strong text-muted-foreground hover:text-foreground"
onClick={addField}
aria-label="添加字段"
title="添加字段"
>
<Plus size={15} />
</Button>
</div>
{config.fields.length === 0 ? (
<div className="rounded-xl border border-dashed border-hairline-strong bg-canvas-soft px-4 py-5 text-center">
<div className="text-sm font-medium text-foreground">
</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
</p>
</div>
) : (
<div className="space-y-1.5">
{config.fields.map((field, index) => (
<div
key={field.id}
className="grid grid-cols-[minmax(0,1fr)_112px_minmax(0,1.2fr)_32px] items-center gap-2"
>
<Input
value={field.name}
onChange={(event) =>
updateField(field.id, { name: event.target.value })
}
placeholder="字段名"
aria-label={`字段 ${index + 1} 名称`}
className="h-9 border-hairline-strong bg-background"
/>
<Select
value={field.type}
onValueChange={(type: AnalysisFieldType) =>
updateField(field.id, {
type,
enumValues: type === "enum" ? field.enumValues : [],
})
}
>
<SelectTrigger
aria-label={`字段 ${index + 1} 类型`}
className="h-9 w-full border-hairline-strong bg-background"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{FIELD_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{field.type === "enum" ? (
<Input
value={field.enumValues.join(", ")}
onChange={(event) =>
updateField(field.id, {
enumValues: event.target.value
.split(",")
.map((value) => value.trim())
.filter(Boolean),
})
}
placeholder="枚举值,逗号分隔"
aria-label={`字段 ${index + 1} 枚举值`}
className="h-9 border-hairline-strong bg-background"
/>
) : (
<Input
value={field.description}
onChange={(event) =>
updateField(field.id, {
description: event.target.value,
})
}
placeholder="说明(可选)"
aria-label={`字段 ${index + 1} 说明`}
className="h-9 border-hairline-strong bg-background"
/>
)}
<Button
type="button"
size="icon-sm"
variant="ghost"
className="shrink-0 text-muted-soft hover:text-destructive"
onClick={() => removeField(field.id)}
aria-label={`删除字段 ${index + 1}`}
>
<Trash2 size={14} />
</Button>
</div>
))}
</div>
)}
</div>
</>
)}
</div>
);
}
type WebhookConfigEditorProps = {
config: WebhookConfig;
onChange: (config: WebhookConfig) => void;
analysisFields: AnalysisField[];
};
export function WebhookConfigEditor({
config,
onChange,
analysisFields,
}: WebhookConfigEditorProps) {
const [testStatus, setTestStatus] = useState<
"idle" | "loading" | "success" | "error"
>("idle");
const [testMessage, setTestMessage] = useState<string | null>(null);
function patch(partial: Partial<WebhookConfig>) {
onChange({ ...config, ...partial });
}
async function sendTestEvent() {
if (!config.url.trim()) {
setTestStatus("error");
setTestMessage("请先填写 Webhook URL。");
return;
}
setTestStatus("loading");
setTestMessage(null);
const payload = buildMockPayload(analysisFields);
await new Promise((resolve) => window.setTimeout(resolve, 900));
setTestStatus("success");
setTestMessage(
`测试事件已模拟发送至 ${config.url.trim()}Mock未实际请求网络。示例 payload${JSON.stringify(payload)}`,
);
}
return (
<div className="space-y-3">
<label className="block">
<span className="mb-1.5 block text-sm font-medium text-foreground">
Webhook URL
</span>
<Input
value={config.url}
onChange={(event) => patch({ url: event.target.value })}
placeholder="https://example.com/webhooks/call-analysis"
className="border-hairline-strong bg-background"
/>
</label>
<label className="block">
<span className="mb-1.5 block text-sm font-medium text-foreground">
</span>
<Input
type="password"
value={config.secret}
onChange={(event) => patch({ secret: event.target.value })}
placeholder="可选,用于验证 Webhook 请求来源"
className="border-hairline-strong bg-background"
/>
</label>
<div className="flex flex-wrap items-center gap-3 border-t border-hairline pt-3">
<Button
type="button"
variant="outline"
className="gap-2 border-hairline-strong"
disabled={testStatus === "loading"}
onClick={() => void sendTestEvent()}
>
{testStatus === "loading" ? (
<Loader2 size={15} className="animate-spin" />
) : (
<Send size={15} />
)}
</Button>
{testStatus === "success" && (
<span className="text-xs text-emerald-600 dark:text-emerald-400">
</span>
)}
{testStatus === "error" && (
<span className="text-xs text-destructive"></span>
)}
</div>
{testMessage && (
<p
role="status"
className={`rounded-xl border px-3.5 py-3 text-xs leading-5 ${
testStatus === "error"
? "border-destructive/30 bg-destructive/5 text-destructive"
: "border-hairline bg-canvas-soft text-muted-foreground"
}`}
>
{testMessage}
</p>
)}
</div>
);
}

View File

@@ -1,16 +1,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>

View File

@@ -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 === "/"}

View File

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

View File

@@ -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(" ")}

View 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>
);
}

View File

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

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

View 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>
);
}

View 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: [
"内置合规免责声明",
"工作流分支:轻症建议 / 建议门诊 / 紧急提醒",
"可对接预约 APIMock",
],
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:0022:00 开放,位于 3 层。自助早餐 6:3010: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: [
"多分支路由:咨询 / 投诉 / 紧急故障",
"可对接工单系统创建 APIMock",
"支持转人工节点",
],
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: [
"课程匹配 → 试听预约 → 优惠说明",
"支持留资节点写入 CRMMock",
"可插入固定话术节点",
],
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);
}